diff --git a/docker/build/build_dev.sh b/docker/build/build_dev.sh index 2da820bf7d..98efacfd57 100755 --- a/docker/build/build_dev.sh +++ b/docker/build/build_dev.sh @@ -62,6 +62,8 @@ if [ -z "$1" ]; then build "hawkbit-update-server" # db init build "hawkbit-repository-jpa-init" + # mcp server + build "hawkbit-mcp-server" else echo "Build $1" build $1 diff --git a/hawkbit-mcp/README.md b/hawkbit-mcp/README.md new file mode 100644 index 0000000000..7c92f04c06 --- /dev/null +++ b/hawkbit-mcp/README.md @@ -0,0 +1,109 @@ +# hawkBit MCP Server + +A standalone [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that provides AI assistants with tools to interact with [Eclipse hawkBit](https://www.eclipse.org/hawkbit/) for IoT device software update management. + +## Building + +From the project root directory: + +```bash +mvn clean package -pl hawkbit-mcp -am -DskipTests +``` + +The JAR will be created at: `hawkbit-mcp/target/hawkbit-mcp-server-0-SNAPSHOT.jar` + +## Configuration + +The MCP server supports two transport modes: + +| Mode | Use Case | Authentication | +|------|----------|----------------| +| **HTTP/SSE** | Remote access, multi-user | Per-request via `Authorization` header | +| **STDIO** | Local CLI tools (e.g., Claude Code) | Environment variables | + + +### HTTP Transport + +Use HTTP transport when running the server as a standalone service: + +```json +{ + "mcpServers": { + "hawkbit-mcp": { + "type": "http", + "url": "http://localhost:8081/mcp", + "headers": { + "Authorization": "Basic " + } + } + } +} +``` + +Start the server separately: + +```bash +java -jar hawkbit-mcp-server-0-SNAPSHOT.jar \ + --hawkbit.mcp.mgmt-url= +``` + +**Generating Base64 credentials:** + +```bash +# Linux/Mac +echo -n ":" | base64 + +# PowerShell +[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(":")) +``` + +### STDIO Transport + +Use STDIO transport for direct integration: + +```json +{ + "mcpServers": { + "hawkbit-mcp": { + "command": "java", + "args": [ + "-Dspring.ai.mcp.server.stdio=true", + "-Dspring.main.web-application-type=none", + "-jar", + "/path/to/hawkbit-mcp-server-0-SNAPSHOT.jar" + ], + "env": { + "HAWKBIT_URL": "", + "HAWKBIT_USERNAME": "", + "HAWKBIT_PASSWORD": "" + } + } + } +} +``` + +## Configuration Properties + +| Property | Environment Variable | Description | Default | +|----------|---------------------|-------------|---------| +| `hawkbit.mcp.mgmt-url` | `HAWKBIT_URL` | hawkBit Management API URL | `http://localhost:8080` | +| `hawkbit.mcp.username` | `HAWKBIT_USERNAME` | Username for STDIO mode | - | +| `hawkbit.mcp.password` | `HAWKBIT_PASSWORD` | Password for STDIO mode | - | +| `hawkbit.mcp.validation.enabled` | - | Validate credentials against hawkBit | `true` | +| `hawkbit.mcp.validation.cache-ttl` | - | Cache TTL for auth validation | `600s` | + +### Operation Controls + +You can enable/disable specific operations globally or per-entity: + +```properties +# Global: disable all deletes +hawkbit.mcp.operations.delete-enabled=false + +# Per-entity: allow delete for targets only +hawkbit.mcp.operations.targets.delete-enabled=true + +# Disable rollout lifecycle operations +hawkbit.mcp.operations.rollouts.start-enabled=false +hawkbit.mcp.operations.rollouts.approve-enabled=false +``` diff --git a/hawkbit-mcp/pom.xml b/hawkbit-mcp/pom.xml new file mode 100644 index 0000000000..c3ad91250f --- /dev/null +++ b/hawkbit-mcp/pom.xml @@ -0,0 +1,135 @@ + + + 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 + false + + + + repackage + + repackage + + + exec + + + + + + + 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..eb3c7c6746 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/HawkbitMcpServerApplication.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server; + +import org.eclipse.hawkbit.mcp.server.config.HawkbitMcpProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; + +/** + * Standalone MCP Server application that connects to hawkBit via REST API. + *

+ * 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/AuthenticationValidator.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/AuthenticationValidator.java new file mode 100644 index 0000000000..870be1f88c --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/AuthenticationValidator.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.client; + +/** + * Interface for authentication validation. + * Implementations can validate credentials against hawkBit or provide no-op validation. + */ +public interface AuthenticationValidator { + + /** + * Validates the given authorization header. + * + * @param authHeader the Authorization header value + * @return validation result + */ + ValidationResult validate(String authHeader); + + /** + * Result of authentication validation. + */ + enum ValidationResult { + /** + * Credentials are valid (authenticated user). + */ + VALID, + + /** + * No credentials provided. + */ + MISSING_CREDENTIALS, + + /** + * Credentials are invalid (401 from hawkBit). + */ + INVALID_CREDENTIALS, + + /** + * hawkBit is unavailable or returned unexpected error. + */ + HAWKBIT_ERROR + } +} 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..274936c63e --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/HawkbitAuthenticationValidator.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.client; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import feign.FeignException; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.mcp.server.config.HawkbitMcpProperties; +import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi; +import org.eclipse.hawkbit.sdk.HawkbitClient; +import org.eclipse.hawkbit.sdk.Tenant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Validates authentication credentials against hawkBit REST API using the SDK. + * This validator is conditionally created when {@code hawkbit.mcp.validation.enabled=true}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "hawkbit.mcp.validation.enabled", havingValue = "true", matchIfMissing = true) +public class HawkbitAuthenticationValidator implements AuthenticationValidator { + + private final HawkbitClient hawkbitClient; + private final Tenant dummyTenant; + private final Cache validationCache; + + public HawkbitAuthenticationValidator(final HawkbitClient hawkbitClient, + final Tenant dummyTenant, + final HawkbitMcpProperties properties) { + this.hawkbitClient = hawkbitClient; + this.dummyTenant = dummyTenant; + + this.validationCache = Caffeine.newBuilder() + .expireAfterWrite(properties.getValidation().getCacheTtl()) + .maximumSize(properties.getValidation().getCacheMaxSize()) + .build(); + + log.info("Authentication validation enabled with cache TTL={}, maxSize={}", + properties.getValidation().getCacheTtl(), + properties.getValidation().getCacheMaxSize()); + } + + /** + * Validates the given authorization header against hawkBit. + * + * @param authHeader the Authorization header value + * @return validation result + */ + @Override + public ValidationResult validate(final String authHeader) { + if (authHeader == null || authHeader.isBlank()) { + return ValidationResult.MISSING_CREDENTIALS; + } + + String cacheKey = hashAuthHeader(authHeader); + Boolean cachedResult = validationCache.getIfPresent(cacheKey); + + if (cachedResult != null) { + log.debug("Authentication validation cache hit: valid={}", cachedResult); + return cachedResult ? ValidationResult.VALID : ValidationResult.INVALID_CREDENTIALS; + } + + return validateWithHawkbit(cacheKey); + } + + private ValidationResult validateWithHawkbit(final String cacheKey) { + log.debug("Validating authentication against hawkBit using SDK"); + + try { + MgmtTenantManagementRestApi tenantApi = hawkbitClient.mgmtService( + MgmtTenantManagementRestApi.class, dummyTenant); + + ResponseEntity response = tenantApi.getTenantConfiguration(); + int statusCode = response.getStatusCode().value(); + + if (statusCode >= 200 && statusCode < 300) { + log.debug("Authentication valid (status={})", statusCode); + validationCache.put(cacheKey, true); + return ValidationResult.VALID; + } else { + log.warn("Unexpected status from hawkBit during auth validation: {}", statusCode); + return ValidationResult.HAWKBIT_ERROR; + } + } catch (FeignException.Unauthorized e) { + log.debug("Authentication invalid (status=401)"); + validationCache.put(cacheKey, false); + return ValidationResult.INVALID_CREDENTIALS; + } catch (FeignException.Forbidden e) { + // 403 = Valid credentials but lacks READ_TENANT_CONFIGURATION permission + // User is authenticated in hawkBit but doesn't have this specific permission + log.debug("Authentication valid but lacks permission (status=403)"); + validationCache.put(cacheKey, true); + return ValidationResult.VALID; + } catch (FeignException e) { + log.warn("Error validating authentication against hawkBit: {} - {}", + e.getClass().getSimpleName(), e.getMessage()); + return ValidationResult.HAWKBIT_ERROR; + } catch (Exception e) { + // Unexpected errors, don't cache, fail closed + log.warn("Unexpected error validating authentication against hawkBit: {}", e.getMessage()); + return ValidationResult.HAWKBIT_ERROR; + } + } + + private String hashAuthHeader(final String authHeader) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(authHeader.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is always available + throw new McpAuthenticationException("SHA-256 not available." + e.getMessage()); + } + } +} 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..9f07ceb06b --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/McpAuthenticationException.java @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.client; + +public class McpAuthenticationException extends RuntimeException { + + public McpAuthenticationException(String message) { + super(message); + } +} 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..78caefe5e9 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkbitClientConfiguration.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import feign.Contract; +import feign.RequestInterceptor; +import feign.codec.Decoder; +import feign.codec.Encoder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.sdk.Controller; +import org.eclipse.hawkbit.sdk.HawkbitClient; +import org.eclipse.hawkbit.sdk.HawkbitServer; +import org.eclipse.hawkbit.sdk.Tenant; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.function.BiFunction; + +/** + * Common configuration for the hawkBit SDK client. + *

+ * Provides the {@link HawkbitServer} and {@link HawkbitClient} beans. + * Mode-specific beans (Tenant, request interceptor) are provided by: + *

    + *
  • {@link McpHttpClientConfiguration} - for HTTP mode (per-request authentication)
  • + *
  • {@link McpStdioClientConfiguration} - for STDIO mode (static credentials)
  • + *
+ *

+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class HawkbitClientConfiguration { + + private final HawkbitMcpProperties properties; + + @Bean + @Primary + public HawkbitServer hawkbitServer() { + HawkbitServer server = new HawkbitServer(); + server.setMgmtUrl(properties.getMgmtUrl()); + log.info("Configured hawkBit server URL: {}", properties.getMgmtUrl()); + return server; + } + + @Bean + public HawkbitClient hawkbitClient( + final HawkbitServer server, + final Encoder encoder, + final Decoder decoder, + final Contract contract, + final BiFunction hawkbitRequestInterceptor) { + log.info("Configuring hawkBit client"); + return HawkbitClient.builder() + .hawkBitServer(server) + .encoder(encoder) + .decoder(decoder) + .contract(contract) + .requestInterceptorFn(hawkbitRequestInterceptor) + .build(); + } + +} 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..c2ebec782c --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkbitMcpProperties.java @@ -0,0 +1,225 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +/** + * Configuration properties for the standalone hawkBit MCP server. + */ +@Data +@Validated +@ConfigurationProperties(prefix = "hawkbit.mcp") +public class HawkbitMcpProperties { + + /** + * Base URL of the hawkBit Management API (e.g., ...). + */ + @NotBlank(message = "hawkbit.mcp.mgmt-url must be configured") + private String mgmtUrl; + + /** + * Username for hawkBit authentication (used in STDIO mode). + * Read directly from HAWKBIT_USERNAME environment variable. + */ + @Value("${HAWKBIT_USERNAME:#{null}}") + private String username; + + /** + * Password for hawkBit authentication (used in STDIO mode). + * Read directly from HAWKBIT_PASSWORD environment variable. + */ + @Value("${HAWKBIT_PASSWORD:#{null}}") + private String password; + + /** + * Check if static credentials are configured. + * Allows empty strings as valid values (for users who intentionally set empty password). + */ + public boolean hasStaticCredentials() { + return username != null && password != null; + } + + /** + * Whether to enable the built-in hawkBit tools. + * Set to false to provide custom tool implementations. + */ + private boolean toolsEnabled = true; + + /** + * Whether to enable the built-in hawkBit documentation resources. + * Set to false to provide custom resource implementations. + */ + private boolean resourcesEnabled = true; + + /** + * Whether to enable the built-in hawkBit prompts. + * Set to false to provide custom prompt implementations. + */ + private boolean promptsEnabled = true; + + /** + * Authentication validation configuration. + */ + private Validation validation = new Validation(); + + /** + * Operations configuration for enabling/disabling specific operations. + */ + private Operations operations = new Operations(); + + /** + * Configuration for pre-authentication validation against hawkBit. + */ + @Data + public static class Validation { + + /** + * Whether to validate authentication against hawkBit before processing MCP requests. + */ + private boolean enabled = true; + + /** + * Duration to cache authentication validation results. + * Shorter values are more secure but increase load on hawkBit. + */ + private Duration cacheTtl = Duration.ofSeconds(60); + + /** + * Maximum number of entries in the authentication validation cache. + */ + private int cacheMaxSize = 1000; + } + + /** + * Configuration for enabling/disabling operations at global and per-entity levels. + */ + @Data + public static class Operations { + + // Global defaults + private boolean listEnabled = true; + private boolean createEnabled = true; + private boolean updateEnabled = true; + private boolean deleteEnabled = true; + + // Per-entity overrides (null = use global) + private EntityConfig targets = new EntityConfig(); + private RolloutConfig rollouts = new RolloutConfig(); + private EntityConfig distributionSets = new EntityConfig(); + private ActionConfig actions = new ActionConfig(); + private EntityConfig softwareModules = new EntityConfig(); + private EntityConfig targetFilters = new EntityConfig(); + + /** + * Check if an operation is enabled globally. + */ + public boolean isGlobalOperationEnabled(final String operation) { + return switch (operation.toLowerCase()) { + case "list" -> listEnabled; + case "create" -> createEnabled; + case "update" -> updateEnabled; + case "delete" -> deleteEnabled; + default -> true; + }; + } + } + + /** + * Per-entity operation configuration. + */ + @Data + public static class EntityConfig { + + private Boolean listEnabled; + private Boolean createEnabled; + private Boolean updateEnabled; + private Boolean deleteEnabled; + + /** + * Get the enabled state for an operation, or null if not set (use global). + */ + public Boolean getOperationEnabled(final String operation) { + return switch (operation.toLowerCase()) { + case "list" -> listEnabled; + case "create" -> createEnabled; + case "update" -> updateEnabled; + case "delete" -> deleteEnabled; + default -> null; + }; + } + } + + /** + * Rollout-specific operation configuration including lifecycle operations. + */ + @Data + @EqualsAndHashCode(callSuper = true) + public static class RolloutConfig extends EntityConfig { + + private Boolean startEnabled = true; + private Boolean pauseEnabled = true; + private Boolean stopEnabled = true; + private Boolean resumeEnabled = true; + private Boolean approveEnabled = true; + private Boolean denyEnabled = true; + private Boolean retryEnabled = true; + private Boolean triggerNextGroupEnabled = true; + + @Override + public Boolean getOperationEnabled(final String operation) { + final Boolean baseResult = super.getOperationEnabled(operation); + if (baseResult != null) { + return baseResult; + } + return switch (operation.toLowerCase().replace("_", "-")) { + case "start" -> startEnabled; + case "pause" -> pauseEnabled; + case "stop" -> stopEnabled; + case "resume" -> resumeEnabled; + case "approve" -> approveEnabled; + case "deny" -> denyEnabled; + case "retry" -> retryEnabled; + case "trigger-next-group" -> triggerNextGroupEnabled; + default -> null; + }; + } + } + + /** + * Action-specific operation configuration. + */ + @Data + public static class ActionConfig { + + private Boolean listEnabled; + private Boolean deleteEnabled; + private Boolean deleteBatchEnabled = true; + + /** + * Get the enabled state for an operation, or null if not set (use global). + */ + public Boolean getOperationEnabled(final String operation) { + return switch (operation.toLowerCase().replace("_", "-")) { + case "list" -> listEnabled; + case "delete" -> deleteEnabled; + case "delete-batch" -> deleteBatchEnabled; + default -> null; + }; + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpHttpClientConfiguration.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpHttpClientConfiguration.java new file mode 100644 index 0000000000..bf41a641fd --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpHttpClientConfiguration.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import feign.RequestInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.sdk.Controller; +import org.eclipse.hawkbit.sdk.Tenant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.function.BiFunction; + +/** + * Configuration for HTTP mode. + *

+ * In HTTP mode, authentication is extracted from the incoming HTTP request's + * Authorization header and forwarded to hawkBit (per-request authentication). + *

+ */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "false", matchIfMissing = true) +public class McpHttpClientConfiguration { + + /** + * Request interceptor for HTTP mode - extracts authentication from incoming HTTP request. + */ + @Bean + public BiFunction hawkbitRequestInterceptor() { + log.info("Configuring HTTP mode request interceptor (per-request authentication)"); + return (tenant, controller) -> template -> { + final ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + final HttpServletRequest request = attrs.getRequest(); + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader != null) { + template.header(HttpHeaders.AUTHORIZATION, authHeader); + log.trace("Using auth header from HTTP request"); + } else { + log.warn("No authentication header in request - request will likely fail"); + } + } else { + log.warn("No request context available - request will likely fail"); + } + }; + } + + /** + * Tenant bean for HTTP mode - credentials are null as authentication + * comes from the incoming HTTP request context via the interceptor. + */ + @Bean + public Tenant dummyTenant() { + log.info("Configured tenant for HTTP mode (per-request authentication)"); + return new Tenant(); + } + +} 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..1c5886ae29 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpSecurityConfiguration.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.mcp.server.client.AuthenticationValidator; +import org.eclipse.hawkbit.mcp.server.client.AuthenticationValidator.ValidationResult; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Optional; + +/** + * Security configuration for the MCP server. + *

+ * This configuration is only active in HTTP/servlet mode. In STDIO mode, + * authentication is handled via static credentials from properties. + *

+ *

+ * When authentication validation is enabled ({@code hawkbit.mcp.validation.enabled=true}), + * a filter validates credentials against hawkBit before forwarding requests. + * When disabled, no validation filter is added and requests pass through directly. + *

+ */ +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "false", matchIfMissing = true) +public class McpSecurityConfiguration { + + private final Optional authenticationValidator; + + @Bean + @SuppressWarnings("java:S4502") // CSRF protection is not needed for stateless REST APIs using Authorization header + public SecurityFilterChain mcpSecurityFilterChain(final HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + authenticationValidator.ifPresentOrElse( + validator -> { + log.info("Authentication validation enabled - adding validation filter"); + http.addFilterBefore(new HawkBitAuthenticationFilter(validator), + UsernamePasswordAuthenticationFilter.class); + }, + () -> log.info("Authentication validation disabled - requests will be forwarded without validation") + ); + + return http.build(); + } + + /** + * Filter that validates authentication against hawkBit. + *

+ * Only added to the filter chain when authentication validation is enabled. + *

+ */ + @Slf4j + @RequiredArgsConstructor + public static class HawkBitAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationValidator validator; + + @Override + protected void doFilterInternal(final HttpServletRequest request, final @NonNull HttpServletResponse response, + final @NonNull FilterChain filterChain) throws ServletException, IOException { + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + final ValidationResult result = validator.validate(authHeader); + + switch (result) { + case VALID -> filterChain.doFilter(request, response); + case MISSING_CREDENTIALS -> { + log.debug("Rejecting request: missing credentials"); + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, + "Authentication required. Please provide hawkBit credentials."); + } + case INVALID_CREDENTIALS -> { + log.debug("Rejecting request: invalid credentials"); + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, + "Invalid hawkBit credentials."); + } + case HAWKBIT_ERROR -> { + log.warn("Rejecting request: hawkBit unavailable"); + sendErrorResponse(response, HttpStatus.SERVICE_UNAVAILABLE, + "Unable to validate credentials. hawkBit may be unavailable."); + } + } + } + + private void sendErrorResponse(final HttpServletResponse response, final HttpStatus status, final String message) + throws IOException { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(String.format( + "{\"error\":\"%s\",\"message\":\"%s\"}", + status.getReasonPhrase(), + message)); + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpStdioClientConfiguration.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpStdioClientConfiguration.java new file mode 100644 index 0000000000..bca2bdfdfa --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpStdioClientConfiguration.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import feign.RequestInterceptor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.sdk.Controller; +import org.eclipse.hawkbit.sdk.Tenant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.function.BiFunction; + +/** + * Configuration for STDIO mode. + *

+ * In STDIO mode, authentication uses static credentials from configuration properties + * (environment variables HAWKBIT_USERNAME and HAWKBIT_PASSWORD). + *

+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "true") +public class McpStdioClientConfiguration { + + private final HawkbitMcpProperties properties; + + /** + * Request interceptor for STDIO mode - uses static credentials from configuration. + */ + @Bean + public BiFunction hawkbitRequestInterceptor() { + log.info("Configuring STDIO mode request interceptor (static credentials)"); + return (tenant, controller) -> template -> { + if (properties.hasStaticCredentials()) { + String credentials = properties.getUsername() + ":" + properties.getPassword(); + String authHeader = "Basic " + Base64.getEncoder().encodeToString( + credentials.getBytes(StandardCharsets.UTF_8)); + template.header(HttpHeaders.AUTHORIZATION, authHeader); + log.trace("Using static credentials from properties (STDIO mode)"); + } else { + log.warn("No static credentials configured for STDIO mode - request will likely fail"); + } + }; + } + + /** + * Tenant bean for STDIO mode - uses static credentials from configuration properties. + */ + @Bean + public Tenant dummyTenant() { + final Tenant tenant = new Tenant(); + if (properties.hasStaticCredentials()) { + tenant.setUsername(properties.getUsername()); + tenant.setPassword(properties.getPassword()); + log.info("Configured tenant with static credentials for STDIO mode"); + } else { + log.warn("STDIO mode enabled but no static credentials configured"); + } + return tenant; + } + +} 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..6320303e22 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpToolConfiguration.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import org.eclipse.hawkbit.mcp.server.prompts.HawkbitPromptProvider; +import org.eclipse.hawkbit.mcp.server.resources.HawkbitDocumentationResource; +import org.eclipse.hawkbit.mcp.server.tools.HawkbitMcpToolProvider; +import org.eclipse.hawkbit.sdk.HawkbitClient; +import org.eclipse.hawkbit.sdk.Tenant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for MCP tools, resources, and prompts. + *

+ *

    + *
  • 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/ActionRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionRequest.java new file mode 100644 index 0000000000..3475002c2c --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionRequest.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.util.List; + +/** + * Sealed interface for action management operations. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = ActionRequest.Delete.class, name = "Delete"), + @JsonSubTypes.Type(value = ActionRequest.DeleteBatch.class, name = "DeleteBatch") +}) +public sealed interface ActionRequest + permits ActionRequest.Delete, ActionRequest.DeleteBatch { + + /** + * Request to delete a single action. + * + * @param actionId to delete + */ + record Delete(Long actionId) implements ActionRequest {} + + /** + * Request to delete multiple actions. + * + * @param actionIds list of action IDs to delete (mutually exclusive with rsql) + * @param rsql RSQL filter query for selecting actions to delete (mutually exclusive with actionIds) + */ + record DeleteBatch(List actionIds, String rsql) implements ActionRequest {} +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/DistributionSetRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/DistributionSetRequest.java new file mode 100644 index 0000000000..b5d4000521 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/DistributionSetRequest.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPut; + +/** + * Sealed interface for distribution set management operations. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = DistributionSetRequest.Create.class, name = "Create"), + @JsonSubTypes.Type(value = DistributionSetRequest.Update.class, name = "Update"), + @JsonSubTypes.Type(value = DistributionSetRequest.Delete.class, name = "Delete") +}) +public sealed interface DistributionSetRequest + permits DistributionSetRequest.Create, DistributionSetRequest.Update, DistributionSetRequest.Delete { + + /** + * Request to create a new distribution set. + * + * @param body the request body containing distribution set data (name, version, type) + */ + record Create(MgmtDistributionSetRequestBodyPost body) implements DistributionSetRequest {} + + /** + * Request to update an existing distribution set. + * + * @param distributionSetId the distribution set ID + * @param body the request body containing updated distribution set data + */ + record Update(Long distributionSetId, MgmtDistributionSetRequestBodyPut body) implements DistributionSetRequest {} + + /** + * Request to delete a distribution set. + * + * @param distributionSetId the distribution set ID to delete + */ + record Delete(Long distributionSetId) implements DistributionSetRequest {} +} 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..bc425a503f --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ListRequest.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +/** + * Common request parameters for list operations. + */ +public record ListRequest( + @JsonPropertyDescription("RSQL filter query (e.g., 'name==test*')") + String rsql, + + @JsonPropertyDescription("Number of items to skip (default: 0)") + Integer offset, + + @JsonPropertyDescription("Maximum number of items to return (default: 50)") + Integer limit +) { + + public static final int DEFAULT_OFFSET = 0; + public static final int DEFAULT_LIMIT = 50; + + public int getOffsetOrDefault() { + return offset != null ? offset : DEFAULT_OFFSET; + } + + public int getLimitOrDefault() { + return limit != null ? limit : DEFAULT_LIMIT; + } + + public String getRsqlOrNull() { + return rsql != null && !rsql.isBlank() ? rsql : null; + } +} 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..40a056e28b --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/OperationResponse.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +/** + * Unified response wrapper for management operations. + * + * @param 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..c26107ddce --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/PagedResponse.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import java.util.List; + +/** + * Generic paged response for MCP tool results. + * + * @param 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/RolloutRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutRequest.java new file mode 100644 index 0000000000..02be86c1fc --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutRequest.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPut; + +/** + * Sealed interface for rollout management operations including CRUD and lifecycle. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = RolloutRequest.Create.class, name = "Create"), + @JsonSubTypes.Type(value = RolloutRequest.Update.class, name = "Update"), + @JsonSubTypes.Type(value = RolloutRequest.Delete.class, name = "Delete"), + @JsonSubTypes.Type(value = RolloutRequest.Start.class, name = "Start"), + @JsonSubTypes.Type(value = RolloutRequest.Pause.class, name = "Pause"), + @JsonSubTypes.Type(value = RolloutRequest.Stop.class, name = "Stop"), + @JsonSubTypes.Type(value = RolloutRequest.Resume.class, name = "Resume"), + @JsonSubTypes.Type(value = RolloutRequest.Approve.class, name = "Approve"), + @JsonSubTypes.Type(value = RolloutRequest.Deny.class, name = "Deny"), + @JsonSubTypes.Type(value = RolloutRequest.Retry.class, name = "Retry"), + @JsonSubTypes.Type(value = RolloutRequest.TriggerNextGroup.class, name = "TriggerNextGroup") +}) +public sealed interface RolloutRequest + permits RolloutRequest.Create, RolloutRequest.Update, RolloutRequest.Delete, + RolloutRequest.Start, RolloutRequest.Pause, RolloutRequest.Stop, + RolloutRequest.Resume, RolloutRequest.Approve, RolloutRequest.Deny, + RolloutRequest.Retry, RolloutRequest.TriggerNextGroup { + + /** + * Request to create a new rollout. + * + * @param body the request body containing rollout data + */ + record Create(MgmtRolloutRestRequestBodyPost body) implements RolloutRequest {} + + /** + * Request to update an existing rollout. + * + * @param rolloutId the rollout ID + * @param body the request body containing updated rollout data + */ + record Update(Long rolloutId, MgmtRolloutRestRequestBodyPut body) implements RolloutRequest {} + + /** + * Request to delete a rollout. + * + * @param rolloutId the rollout ID to delete + */ + record Delete(Long rolloutId) implements RolloutRequest {} + + /** + * Request to start a rollout. + * + * @param rolloutId the rollout ID to start + */ + record Start(Long rolloutId) implements RolloutRequest {} + + /** + * Request to pause a rollout. + * + * @param rolloutId the rollout ID to pause + */ + record Pause(Long rolloutId) implements RolloutRequest {} + + /** + * Request to stop a rollout. + * + * @param rolloutId the rollout ID to stop + */ + record Stop(Long rolloutId) implements RolloutRequest {} + + /** + * Request to resume a paused rollout. + * + * @param rolloutId the rollout ID to resume + */ + record Resume(Long rolloutId) implements RolloutRequest {} + + /** + * Request to approve a rollout. + * + * @param rolloutId the rollout ID to approve + * @param remark optional remark for the approval + */ + record Approve(Long rolloutId, String remark) implements RolloutRequest {} + + /** + * Request to deny a rollout. + * + * @param rolloutId the rollout ID to deny + * @param remark optional remark for the denial + */ + record Deny(Long rolloutId, String remark) implements RolloutRequest {} + + /** + * Request to retry a rollout. + * + * @param rolloutId the rollout ID to retry + */ + record Retry(Long rolloutId) implements RolloutRequest {} + + /** + * Request to trigger the next group in a rollout. + * + * @param rolloutId the rollout ID + */ + record TriggerNextGroup(Long rolloutId) implements RolloutRequest {} +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/SoftwareModuleRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/SoftwareModuleRequest.java new file mode 100644 index 0000000000..11b5bdf33e --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/SoftwareModuleRequest.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPut; + +/** + * Sealed interface for software module management operations. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = SoftwareModuleRequest.Create.class, name = "Create"), + @JsonSubTypes.Type(value = SoftwareModuleRequest.Update.class, name = "Update"), + @JsonSubTypes.Type(value = SoftwareModuleRequest.Delete.class, name = "Delete") +}) +public sealed interface SoftwareModuleRequest + permits SoftwareModuleRequest.Create, SoftwareModuleRequest.Update, SoftwareModuleRequest.Delete { + + /** + * Request to create a new software module. + * + * @param body the request body containing software module data (name, version, type) + */ + record Create(MgmtSoftwareModuleRequestBodyPost body) implements SoftwareModuleRequest {} + + /** + * Request to update an existing software module. + * + * @param softwareModuleId the software module ID + * @param body the request body containing updated software module data + */ + record Update(Long softwareModuleId, MgmtSoftwareModuleRequestBodyPut body) implements SoftwareModuleRequest {} + + /** + * Request to delete a software module. + * + * @param softwareModuleId the software module ID to delete + */ + record Delete(Long softwareModuleId) implements SoftwareModuleRequest {} +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetFilterRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetFilterRequest.java new file mode 100644 index 0000000000..ddab9dc730 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetFilterRequest.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody; + +/** + * Sealed interface for target filter query management operations. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = TargetFilterRequest.Create.class, name = "Create"), + @JsonSubTypes.Type(value = TargetFilterRequest.Update.class, name = "Update"), + @JsonSubTypes.Type(value = TargetFilterRequest.Delete.class, name = "Delete") +}) +public sealed interface TargetFilterRequest + permits TargetFilterRequest.Create, TargetFilterRequest.Update, TargetFilterRequest.Delete { + + /** + * Request to create a new target filter. + * + * @param body the request body containing filter data (name, query) + */ + record Create(MgmtTargetFilterQueryRequestBody body) implements TargetFilterRequest {} + + /** + * Request to update an existing target filter. + * + * @param filterId the target filter ID + * @param body the request body containing updated filter data + */ + record Update(Long filterId, MgmtTargetFilterQueryRequestBody body) implements TargetFilterRequest {} + + /** + * Request to delete a target filter. + * + * @param filterId the target filter ID to delete + */ + record Delete(Long filterId) implements TargetFilterRequest {} +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetRequest.java new file mode 100644 index 0000000000..3f5b6dbebd --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/TargetRequest.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody; + +/** + * Sealed interface for target management operations. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = TargetRequest.Create.class, name = "Create"), + @JsonSubTypes.Type(value = TargetRequest.Update.class, name = "Update"), + @JsonSubTypes.Type(value = TargetRequest.Delete.class, name = "Delete") +}) +public sealed interface TargetRequest + permits TargetRequest.Create, TargetRequest.Update, TargetRequest.Delete { + + /** + * Request to create a new target. + * + * @param body the request body containing target data (controllerId, name, description) + */ + record Create(MgmtTargetRequestBody body) implements TargetRequest {} + + /** + * Request to update an existing target. + * + * @param controllerId the target controller ID + * @param body the request body containing updated target data + */ + record Update(String controllerId, MgmtTargetRequestBody body) implements TargetRequest {} + + /** + * Request to delete a target. + * + * @param controllerId the target controller ID to delete + */ + record Delete(String controllerId) implements TargetRequest {} +} 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..a41176c2ec --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/prompts/HawkbitPromptProvider.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.prompts; + +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import lombok.extern.slf4j.Slf4j; +import org.springaicommunity.mcp.annotation.McpPrompt; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * MCP prompts for hawkBit that provide initial context to LLMs. + *

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

+ * 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. When creating a target without a specific target type, set \"targetType\": null), " + + "UPDATE (modify existing target by controllerId), " + + "DELETE (remove target by controllerId). " + + "Use 'type' field to select operation: " + + "{\"type\":\"Create\",\"body\":{\"controllerId\":\"id\",\"name\":\"name\"}}, " + + "{\"type\":\"Update\",\"controllerId\":\"id\",\"body\":{...}}, " + + "{\"type\":\"Delete\",\"controllerId\":\"id\"}") + public OperationResponse manageTarget(final TargetRequest request) { + log.debug("Managing target: request={}", request.getClass().getSimpleName()); + + final MgmtTargetRestApi api = hawkbitClient.mgmtService(MgmtTargetRestApi.class, dummyTenant); + + if (request instanceof TargetRequest.Create r) { + validateOperation("create", "targets"); + if (r.body() == null) { + return OperationResponse.failure(OP_CREATE, "Request body is required for CREATE operation"); + } + final ResponseEntity> response = api.createTargets(List.of(r.body())); + final List created = response.getBody(); + return OperationResponse.success(OP_CREATE, "Target created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } else if (request instanceof TargetRequest.Update r) { + validateOperation("update", "targets"); + if (r.controllerId() == null || r.controllerId().isBlank()) { + return OperationResponse.failure(OP_UPDATE, "controllerId is required for UPDATE operation"); + } + if (r.body() == null) { + return OperationResponse.failure(OP_UPDATE, "Request body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateTarget(r.controllerId(), r.body()); + return OperationResponse.success(OP_UPDATE, "Target updated successfully", response.getBody()); + } else if (request instanceof TargetRequest.Delete r) { + validateOperation("delete", "targets"); + if (r.controllerId() == null || r.controllerId().isBlank()) { + return OperationResponse.failure(OP_DELETE, "controllerId is required for DELETE operation"); + } + api.deleteTarget(r.controllerId()); + return OperationResponse.success(OP_DELETE, "Target deleted successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + @McpTool(name = "manage_rollout", + description = "Create, update, delete, and control rollouts for software deployment. " + + "Use 'type' field to select operation. " + + "Types: Create, Update, Delete, Start, Pause, Stop, Resume, Approve, Deny, Retry, TriggerNextGroup. " + + "For Create: use rollout 'type' values: 'soft', 'forced', 'timeforced', 'downloadonly' (lowercase). " + + "If 'groups' list is provided, omit 'amountGroups' (they are mutually exclusive). " + + "Examples: {\"type\":\"Create\",\"body\":{...}}, " + + "{\"type\":\"Start\",\"rolloutId\":123}, " + + "{\"type\":\"Approve\",\"rolloutId\":123,\"remark\":\"approved\"}") + public OperationResponse manageRollout(final RolloutRequest request) { + log.debug("Managing rollout: request={}", request.getClass().getSimpleName()); + + final MgmtRolloutRestApi api = hawkbitClient.mgmtService(MgmtRolloutRestApi.class, dummyTenant); + + if (request instanceof RolloutRequest.Create r) { + validateRolloutOperation("create"); + if (r.body() == null) { + return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation"); + } + final ResponseEntity response = api.create(r.body()); + return OperationResponse.success(OP_CREATE, "Rollout created successfully", response.getBody()); + } else if (request instanceof RolloutRequest.Update r) { + validateRolloutOperation("update"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_UPDATE, "rolloutId is required for UPDATE operation"); + } + if (r.body() == null) { + return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation"); + } + final ResponseEntity response = api.update(r.rolloutId(), r.body()); + return OperationResponse.success(OP_UPDATE, "Rollout updated successfully", response.getBody()); + } else if (request instanceof RolloutRequest.Delete r) { + validateRolloutOperation("delete"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_DELETE, "rolloutId is required for DELETE operation"); + } + api.delete(r.rolloutId()); + return OperationResponse.success(OP_DELETE, "Rollout deleted successfully"); + } else if (request instanceof RolloutRequest.Start r) { + validateRolloutOperation("start"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_START, "rolloutId is required for START operation"); + } + api.start(r.rolloutId()); + return OperationResponse.success(OP_START, "Rollout started successfully"); + } else if (request instanceof RolloutRequest.Pause r) { + validateRolloutOperation("pause"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_PAUSE, "rolloutId is required for PAUSE operation"); + } + api.pause(r.rolloutId()); + return OperationResponse.success(OP_PAUSE, "Rollout paused successfully"); + } else if (request instanceof RolloutRequest.Stop r) { + validateRolloutOperation("stop"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_STOP, "rolloutId is required for STOP operation"); + } + api.stop(r.rolloutId()); + return OperationResponse.success(OP_STOP, "Rollout stopped successfully"); + } else if (request instanceof RolloutRequest.Resume r) { + validateRolloutOperation("resume"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_RESUME, "rolloutId is required for RESUME operation"); + } + api.resume(r.rolloutId()); + return OperationResponse.success(OP_RESUME, "Rollout resumed successfully"); + } else if (request instanceof RolloutRequest.Approve r) { + validateRolloutOperation("approve"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_APPROVE, "rolloutId is required for APPROVE operation"); + } + api.approve(r.rolloutId(), r.remark()); + return OperationResponse.success(OP_APPROVE, "Rollout approved successfully"); + } else if (request instanceof RolloutRequest.Deny r) { + validateRolloutOperation("deny"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_DENY, "rolloutId is required for DENY operation"); + } + api.deny(r.rolloutId(), r.remark()); + return OperationResponse.success(OP_DENY, "Rollout denied successfully"); + } else if (request instanceof RolloutRequest.Retry r) { + validateRolloutOperation("retry"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_RETRY, "rolloutId is required for RETRY operation"); + } + final ResponseEntity response = api.retryRollout(r.rolloutId()); + return OperationResponse.success(OP_RETRY, "Rollout retry created successfully", response.getBody()); + } else if (request instanceof RolloutRequest.TriggerNextGroup r) { + validateRolloutOperation("trigger-next-group"); + if (r.rolloutId() == null) { + return OperationResponse.failure(OP_TRIGGER_NEXT_GROUP, "rolloutId is required for TRIGGER_NEXT_GROUP operation"); + } + api.triggerNextGroup(r.rolloutId()); + return OperationResponse.success(OP_TRIGGER_NEXT_GROUP, "Next rollout group triggered successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + @McpTool(name = "manage_distribution_set", + description = "Create, update, or delete distribution sets (software packages). " + + "Use 'type' field to select operation: " + + "{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"version\":\"v\",\"type\":\"t\"}}, " + + "{\"type\":\"Update\",\"distributionSetId\":123,\"body\":{...}}, " + + "{\"type\":\"Delete\",\"distributionSetId\":123}") + public OperationResponse manageDistributionSet(final DistributionSetRequest request) { + log.debug("Managing distribution set: request={}", request.getClass().getSimpleName()); + + final MgmtDistributionSetRestApi api = hawkbitClient.mgmtService(MgmtDistributionSetRestApi.class, dummyTenant); + + if (request instanceof DistributionSetRequest.Create r) { + validateOperation("create", "distributionSets"); + if (r.body() == null) { + return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation"); + } + final ResponseEntity> response = api.createDistributionSets(List.of(r.body())); + final List created = response.getBody(); + return OperationResponse.success(OP_CREATE, "Distribution set created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } else if (request instanceof DistributionSetRequest.Update r) { + validateOperation("update", "distributionSets"); + if (r.distributionSetId() == null) { + return OperationResponse.failure(OP_UPDATE, "distributionSetId is required for UPDATE operation"); + } + if (r.body() == null) { + return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateDistributionSet(r.distributionSetId(), r.body()); + return OperationResponse.success(OP_UPDATE, "Distribution set updated successfully", response.getBody()); + } else if (request instanceof DistributionSetRequest.Delete r) { + validateOperation("delete", "distributionSets"); + if (r.distributionSetId() == null) { + return OperationResponse.failure(OP_DELETE, "distributionSetId is required for DELETE operation"); + } + api.deleteDistributionSet(r.distributionSetId()); + return OperationResponse.success(OP_DELETE, "Distribution set deleted successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + @McpTool(name = "manage_action", + description = "Delete deployment actions. Actions are created indirectly via distribution set assignment. " + + "Use 'type' field to select operation: " + + "{\"type\":\"Delete\",\"actionIds\":[123]}, " + + "{\"type\":\"DeleteBatch\",\"actionIds\":[1,2,3],\"rsql\":\"\"}") + public OperationResponse manageAction(final ActionRequest request) { + log.debug("Managing action: request={}", request.getClass().getSimpleName()); + + final MgmtActionRestApi api = hawkbitClient.mgmtService(MgmtActionRestApi.class, dummyTenant); + + if (request instanceof ActionRequest.Delete r) { + validateActionOperation("delete"); + if (r.actionId() == null) { + return OperationResponse.failure(OP_DELETE, "actionId is required for DELETE operation"); + } + api.deleteAction(r.actionId()); + return OperationResponse.success(OP_DELETE, "Action deleted successfully"); + } else if (request instanceof ActionRequest.DeleteBatch r) { + validateActionOperation("delete-batch"); + if ((r.actionIds() == null || r.actionIds().isEmpty()) && + (r.rsql() == null || r.rsql().isBlank())) { + return OperationResponse.failure(OP_DELETE_BATCH, "Either actionIds or rsql is required for DELETE_BATCH operation"); + } + api.deleteActions(r.rsql(), r.actionIds()); + return OperationResponse.success(OP_DELETE_BATCH, "Actions deleted successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + @McpTool(name = "manage_software_module", + description = "Create, update, or delete software modules. " + + "Use 'type' field to select operation: " + + "{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"version\":\"v\",\"type\":\"t\"}}, " + + "{\"type\":\"Update\",\"softwareModuleId\":123,\"body\":{...}}, " + + "{\"type\":\"Delete\",\"softwareModuleId\":123}") + public OperationResponse manageSoftwareModule(final SoftwareModuleRequest request) { + log.debug("Managing software module: request={}", request.getClass().getSimpleName()); + + final MgmtSoftwareModuleRestApi api = hawkbitClient.mgmtService(MgmtSoftwareModuleRestApi.class, dummyTenant); + + if (request instanceof SoftwareModuleRequest.Create r) { + validateOperation("create", "softwareModules"); + if (r.body() == null) { + return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation"); + } + final ResponseEntity> response = api.createSoftwareModules(List.of(r.body())); + final List created = response.getBody(); + return OperationResponse.success(OP_CREATE, "Software module created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } else if (request instanceof SoftwareModuleRequest.Update r) { + validateOperation("update", "softwareModules"); + if (r.softwareModuleId() == null) { + return OperationResponse.failure(OP_UPDATE, "softwareModuleId is required for UPDATE operation"); + } + if (r.body() == null) { + return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateSoftwareModule(r.softwareModuleId(), r.body()); + return OperationResponse.success(OP_UPDATE, "Software module updated successfully", response.getBody()); + } else if (request instanceof SoftwareModuleRequest.Delete r) { + validateOperation("delete", "softwareModules"); + if (r.softwareModuleId() == null) { + return OperationResponse.failure(OP_DELETE, "softwareModuleId is required for DELETE operation"); + } + api.deleteSoftwareModule(r.softwareModuleId()); + return OperationResponse.success(OP_DELETE, "Software module deleted successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + @McpTool(name = "manage_target_filter", + description = "Create, update, or delete target filter queries. " + + "Use 'type' field to select operation: " + + "{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"query\":\"name==*\"}}, " + + "{\"type\":\"Update\",\"filterId\":123,\"body\":{...}}, " + + "{\"type\":\"Delete\",\"filterId\":123}") + public OperationResponse manageTargetFilter(final TargetFilterRequest request) { + log.debug("Managing target filter: request={}", request.getClass().getSimpleName()); + + final MgmtTargetFilterQueryRestApi api = hawkbitClient.mgmtService(MgmtTargetFilterQueryRestApi.class, dummyTenant); + + if (request instanceof TargetFilterRequest.Create r) { + validateOperation("create", "targetFilters"); + if (r.body() == null) { + return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation"); + } + final ResponseEntity response = api.createFilter(r.body()); + return OperationResponse.success(OP_CREATE, "Target filter created successfully", response.getBody()); + } else if (request instanceof TargetFilterRequest.Update r) { + validateOperation("update", "targetFilters"); + if (r.filterId() == null) { + return OperationResponse.failure(OP_UPDATE, "filterId is required for UPDATE operation"); + } + if (r.body() == null) { + return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateFilter(r.filterId(), r.body()); + return OperationResponse.success(OP_UPDATE, "Target filter updated successfully", response.getBody()); + } else if (request instanceof TargetFilterRequest.Delete r) { + validateOperation("delete", "targetFilters"); + if (r.filterId() == null) { + return OperationResponse.failure(OP_DELETE, "filterId is required for DELETE operation"); + } + api.deleteFilter(r.filterId()); + return OperationResponse.success(OP_DELETE, "Target filter deleted successfully"); + } + throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName()); + } + + + private void validateOperation(final String operation, final String entity) { + if (!isOperationEnabled(operation, entity)) { + throw new IllegalArgumentException( + "Operation " + operation.toUpperCase() + " is not enabled for " + entity + + ". Check hawkbit.mcp.operations configuration."); + } + } + + private void validateRolloutOperation(final String operation) { + final HawkbitMcpProperties.RolloutConfig config = properties.getOperations().getRollouts(); + final Boolean entitySetting = config.getOperationEnabled(operation); + + // For standard CRUD ops, check global fallback + if (entitySetting == null) { + if (!properties.getOperations().isGlobalOperationEnabled(operation)) { + throw new IllegalArgumentException( + "Operation " + operation.toUpperCase() + " is not enabled for rollouts. " + + "Check hawkbit.mcp.operations configuration."); + } + return; + } + + if (!entitySetting) { + throw new IllegalArgumentException( + "Operation " + operation.toUpperCase() + " is not enabled for rollouts. " + + "Check hawkbit.mcp.operations configuration."); + } + } + + private void validateActionOperation(final String operation) { + final HawkbitMcpProperties.ActionConfig config = properties.getOperations().getActions(); + final Boolean entitySetting = config.getOperationEnabled(operation); + + if (entitySetting == null) { + if (operation.equals("delete") && !properties.getOperations().isGlobalOperationEnabled("delete")) { + throw new IllegalArgumentException( + "Operation " + operation.toUpperCase() + " is not enabled for actions. " + + "Check hawkbit.mcp.operations configuration."); + } + return; + } + + if (!entitySetting) { + throw new IllegalArgumentException( + "Operation " + operation.toUpperCase() + " is not enabled for actions. " + + "Check hawkbit.mcp.operations configuration."); + } + } + + private boolean isOperationEnabled(final String operation, final String entity) { + final HawkbitMcpProperties.Operations ops = properties.getOperations(); + final HawkbitMcpProperties.EntityConfig entityConfig = getEntityConfig(entity); + + final Boolean entitySetting = entityConfig != null ? entityConfig.getOperationEnabled(operation) : null; + if (entitySetting != null) { + return entitySetting; + } + + return ops.isGlobalOperationEnabled(operation); + } + + private HawkbitMcpProperties.EntityConfig getEntityConfig(final String entity) { + final HawkbitMcpProperties.Operations ops = properties.getOperations(); + return switch (entity.toLowerCase()) { + case "targets" -> ops.getTargets(); + case "rollouts" -> ops.getRollouts(); + case "distributionsets" -> ops.getDistributionSets(); + case "softwaremodules" -> ops.getSoftwareModules(); + case "targetfilters" -> ops.getTargetFilters(); + default -> null; + }; + } +} diff --git a/hawkbit-mcp/src/main/resources/application.properties b/hawkbit-mcp/src/main/resources/application.properties new file mode 100644 index 0000000000..a6647f26f3 --- /dev/null +++ b/hawkbit-mcp/src/main/resources/application.properties @@ -0,0 +1,54 @@ +# +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# + +# Server configuration +server.port=8081 + +# Jackson configuration - accept both uppercase and lowercase enum values +# This allows LLMs to use either "FORCED" or "forced" for enum fields like MgmtActionType +spring.jackson.mapper.accept-case-insensitive-enums=true + +# Spring application name +spring.application.name=hawkbit-mcp-server + +# Spring AI MCP Server configuration +spring.ai.mcp.server.enabled=true +spring.ai.mcp.server.name=hawkbit-mcp-server +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.type=SYNC +spring.ai.mcp.server.protocol=STREAMABLE +# Change from HTTP to STDIO +#spring.ai.mcp.server.stdio=true +#spring.ai.mcp.server.protocol=STDIO +spring.ai.mcp.server.capabilities.prompt=true + +# hawkBit connection configuration +hawkbit.mcp.mgmt-url=${HAWKBIT_URL:http://localhost:8080} + +# Authentication validation configuration +hawkbit.mcp.validation.cache-ttl=600s +hawkbit.mcp.validation.cache-max-size=1000 + +# Logging configuration +logging.level.org.eclipse.hawkbit.mcp=DEBUG +logging.level.org.springframework.ai.mcp=DEBUG + + +# Global: disable all deletes by default +#hawkbit.mcp.operations.delete-enabled=false +# But allow delete for targets specifically +#hawkbit.mcp.operations.targets.delete-enabled=true + +# Disable rollout lifecycle operations +#hawkbit.mcp.operations.rollouts.start-enabled=false +#hawkbit.mcp.operations.rollouts.approve-enabled=false + +# Disable software modules delete operations +#hawkbit.mcp.operations.software-modules.delete-enabled=false 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/pom.xml b/pom.xml index a4ef62fba3..a045a44125 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 @@ -728,6 +738,7 @@ hawkbit-mgmt hawkbit-ddi hawkbit-dmf + hawkbit-mcp hawkbit-monolith hawkbit-ui