Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions hawkbit-mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#
# Copyright (c) 2025 Contributors to the Eclipse Foundation
#
# This program and the accompanying materials are made
# available under the terms of the Eclipse Public License 2.0
# which is available at https://www.eclipse.org/legal/epl-2.0/
#
# SPDX-License-Identifier: EPL-2.0
#

FROM eclipse-temurin:17-jre-alpine

LABEL maintainer="Eclipse hawkBit Project"
LABEL org.opencontainers.image.source="https://github.com/eclipse-hawkbit/hawkbit"
LABEL org.opencontainers.image.description="Standalone MCP Server for hawkBit"

ARG JAR_FILE=target/*.jar

COPY ${JAR_FILE} app.jar

EXPOSE 8081

ENV HAWKBIT_URL=http://localhost:8080

ENTRYPOINT ["java", "-jar", "/app.jar"]
130 changes: 130 additions & 0 deletions hawkbit-mcp/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<!--

Copyright (c) 2025 Contributors to the Eclipse Foundation

This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/

SPDX-License-Identifier: EPL-2.0

-->
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-parent</artifactId>
<version>${revision}</version>
</parent>

<artifactId>hawkbit-mcp-server</artifactId>
<name>hawkBit :: MCP Server (Standalone)</name>
<description>Standalone MCP server that connects to hawkBit via REST API</description>

<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-sdk-mgmt</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-annotations</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-hawkbit-docs</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/hawkbit-docs</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/../docs</directory>
<includes>
<include>README.md</include>
<include>what-is-hawkbit.md</include>
<include>quick-start.md</include>
<include>features.md</include>
<include>architecture.md</include>
<include>base-setup.md</include>
<include>hawkbit-sdk.md</include>
<include>feign-client.md</include>
<include>clustering.md</include>
<include>authentication.md</include>
<include>authorization.md</include>
<include>datamodel.md</include>
<include>rollout-management.md</include>
<include>targetstate.md</include>
<include>management-api.md</include>
<include>direct-device-integration-api.md</include>
<include>device-management-federation-api.md</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>org.eclipse.hawkbit.mcp.server.HawkBitMcpServerApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server;

import org.eclipse.hawkbit.mcp.server.config.HawkBitMcpProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;

/**
* Standalone MCP Server application that connects to hawkBit via REST API.
* <p>
* This server acts as a proxy between MCP clients and hawkBit,
* passing through authentication credentials to the hawkBit REST API.
* </p>
*/
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@EnableConfigurationProperties(HawkBitMcpProperties.class)
public class HawkBitMcpServerApplication {

public static void main(String[] args) {
SpringApplication.run(HawkBitMcpServerApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.client;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import feign.FeignException;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mcp.server.config.HawkBitMcpProperties;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

/**
* Validates authentication credentials against hawkBit REST API using the SDK.
*/
@Slf4j
@Component
public class HawkBitAuthenticationValidator {

private final HawkbitClient hawkbitClient;
private final Tenant dummyTenant;
private final Cache<String, Boolean> validationCache;
private final boolean enabled;

public HawkBitAuthenticationValidator(HawkbitClient hawkbitClient,
Tenant dummyTenant,
HawkBitMcpProperties properties) {
this.hawkbitClient = hawkbitClient;
this.dummyTenant = dummyTenant;
this.enabled = properties.getValidation().isEnabled();

this.validationCache = Caffeine.newBuilder()
.expireAfterWrite(properties.getValidation().getCacheTtl())
.maximumSize(properties.getValidation().getCacheMaxSize())
.build();

log.info("Authentication validation {} with cache TTL={}, maxSize={}",
enabled ? "enabled" : "disabled",
properties.getValidation().getCacheTtl(),
properties.getValidation().getCacheMaxSize());
}

/**
* Validates the given authorization header against hawkBit.
* @param authHeader the Authorization header value
* @return validation result
*/
public ValidationResult validate(String authHeader) {
if (!enabled) {
return ValidationResult.VALID;
}

if (authHeader == null || authHeader.isBlank()) {
return ValidationResult.MISSING_CREDENTIALS;
}

String cacheKey = hashAuthHeader(authHeader);
Boolean cachedResult = validationCache.getIfPresent(cacheKey);

if (cachedResult != null) {
log.debug("Authentication validation cache hit: valid={}", cachedResult);
return cachedResult ? ValidationResult.VALID : ValidationResult.INVALID_CREDENTIALS;
}

return validateWithHawkBit(cacheKey);
}

private ValidationResult validateWithHawkBit(String cacheKey) {
log.debug("Validating authentication against hawkBit using SDK");

try {
MgmtTenantManagementRestApi tenantApi = hawkbitClient.mgmtService(
MgmtTenantManagementRestApi.class, dummyTenant);

ResponseEntity<?> response = tenantApi.getTenantConfiguration();
int statusCode = response.getStatusCode().value();

if (statusCode >= 200 && statusCode < 300) {
log.debug("Authentication valid (status={})", statusCode);
validationCache.put(cacheKey, true);
return ValidationResult.VALID;
} else {
log.warn("Unexpected status from hawkBit during auth validation: {}", statusCode);
return ValidationResult.HAWKBIT_ERROR;
}
} catch (FeignException.Unauthorized e) {
log.debug("Authentication invalid (status=401)");
validationCache.put(cacheKey, false);
return ValidationResult.INVALID_CREDENTIALS;
} catch (FeignException.Forbidden e) {
// 403 = Valid credentials but lacks READ_TENANT_CONFIGURATION permission
// User is authenticated in hawkBit but doesn't have this specific permission
log.debug("Authentication valid but lacks permission (status=403)");
validationCache.put(cacheKey, true);
return ValidationResult.VALID;
} catch (FeignException e) {
log.warn("Error validating authentication against hawkBit: {} - {}",
e.getClass().getSimpleName(), e.getMessage());
return ValidationResult.HAWKBIT_ERROR;
} catch (Exception e) {
// Unexpected errors, don't cache, fail closed
log.warn("Unexpected error validating authentication against hawkBit: {}", e.getMessage());
return ValidationResult.HAWKBIT_ERROR;
}
}

private String hashAuthHeader(String authHeader) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(authHeader.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
// SHA-256 is always available
throw new McpAuthenticationException("SHA-256 not available." + e.getMessage());
}
}

/**
* Result of authentication validation.
*/
public enum ValidationResult {
/**
* Credentials are valid (authenticated user).
*/
VALID,

/**
* No credentials provided.
*/
MISSING_CREDENTIALS,

/**
* Credentials are invalid (401 from hawkBit).
*/
INVALID_CREDENTIALS,

/**
* hawkBit is unavailable or returned unexpected error.
*/
HAWKBIT_ERROR
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.client;

public class McpAuthenticationException extends RuntimeException {

public McpAuthenticationException(String message) {
super(message);
}
}
Loading
Loading