refactor: migrate loyalty-agent and loyalty-mcp-server components to a new modular structure with updated specs and frontend integration.

This commit is contained in:
2026-07-19 21:43:44 +07:00
parent c3861d6476
commit cda93365ca
128 changed files with 12721 additions and 6343 deletions

View File

@@ -0,0 +1,6 @@
FROM eclipse-temurin:25-jre-alpine
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
EXPOSE 8081
ENTRYPOINT ["java","-jar","/app.jar"]

120
loyalty-mcp-server/pom.xml Normal file
View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.sonpx.loyalty</groupId>
<artifactId>loyalty-agent-service-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>loyalty-mcp-server</artifactId>
<name>loyalty-mcp-server</name>
<description>MCP Server for Loyalty API Tools</description>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- WebClient (needed for generated API client if library is webclient) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.2</version>
</dependency>
<!-- Spring Boot DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Springdoc OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.6.2</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
}

View File

@@ -0,0 +1,16 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
public interface CampaignApiClient {
@GetExchange("/api/campaign/csr/id/generate")
String generateCampaignId();
@PostExchange("/api/campaign/csr/create-draft")
OperationResult<Campaign> createCampaignDraft(@RequestBody Campaign request);
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import org.springframework.web.service.annotation.GetExchange;
import java.util.List;
public interface MemberTierApiClient {
@GetExchange("/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A")
List<MemberTier> listMemberTiers();
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.api;
import dev.sonpx.loyalty.mcp.client.model.PointPool;
import org.springframework.web.service.annotation.GetExchange;
import java.util.List;
public interface PointPoolApiClient {
@GetExchange("/api/pool-definition/all")
List<PointPool> listPointPools();
}

View File

@@ -0,0 +1,7 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record Campaign(String campaignId, String name, String targetTierId, String rewardPoolId, String status) {
}

View File

@@ -0,0 +1,8 @@
package dev.sonpx.loyalty.mcp.client.model;
public record CreateCampaignRequest(String campaignId, String name, String targetTierId, String rewardPoolId) {
public CreateCampaignRequest(String name, String targetTierId, String rewardPoolId) {
this(null, name, targetTierId, rewardPoolId);
}
}

View File

@@ -0,0 +1,12 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public record MemberTier(
@JsonProperty("key") @JsonAlias("tierId") String tierId,
@JsonAlias("name") String tierName,
Integer requiredPoints) {
}

View File

@@ -0,0 +1,7 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OperationResult<T>(String errorCode, String message, T entity) {
}

View File

@@ -0,0 +1,9 @@
package dev.sonpx.loyalty.mcp.client.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.math.BigDecimal;
@JsonIgnoreProperties(ignoreUnknown = true)
public record PointPool(String poolId, String poolName, BigDecimal balance) {
}

View File

@@ -0,0 +1,13 @@
package dev.sonpx.loyalty.mcp.client.model.attribute;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class StaticAttributeDto {
private String attributeId;
private String attributeType;
private String attributeValue;
private String status;
}

View File

@@ -0,0 +1,26 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignDto {
private String campaignId;
private String name;
private String targetTierId;
private String rewardPoolId;
private String status;
private String description;
private Map<String, Object> extraData = new LinkedHashMap<>();
@JsonAnySetter
public void addExtraData(String key, Object value) {
this.extraData.put(key, value);
}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignFormulaOneDto {
private Double awardPerTxnAmt;
private Double txnAmt;
}

View File

@@ -0,0 +1,57 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignRuleDto {
private ReferenceData campaignId;
private String ruleId;
private String ruleName;
private RuleTypeEnum ruleType;
private String description;
private OffsetDateTime effectiveFrom;
private OffsetDateTime effectiveTo;
private ReferenceData poolId;
private CampaignFormulaOneDto campaignFormulaOne;
private List<String> formulaSequence;
public enum RuleTypeEnum {
AWD("AWD"),
RED("RED"),
ADJ("ADJ"),
REP("REP"),
MAWD("MAWD"),
CEP("CEP");
private String value;
RuleTypeEnum(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
@Override
public String toString() {
return String.valueOf(value);
}
public static RuleTypeEnum fromValue(String value) {
for (RuleTypeEnum b : RuleTypeEnum.values()) {
if (b.value.equals(value)) {
return b;
}
}
throw new IllegalArgumentException("Unexpected value '" + value + "'");
}
}
}

View File

@@ -0,0 +1,13 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class OperationResultCampaignRuleDto {
private Boolean success;
private String errorCode;
private String errorMessage;
private CampaignRuleDto data;
}

View File

@@ -0,0 +1,10 @@
package dev.sonpx.loyalty.mcp.client.model.reward;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ReferenceData {
private String code;
}

View File

@@ -0,0 +1,105 @@
package dev.sonpx.loyalty.mcp.config;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Configures OAuth2-enabled RestClient instances and Spring HTTP interface clients.
*/
@Configuration
public class ApiClientConfig {
@Value("${loyalty.core.base-url}")
private String coreBaseUrl;
@Bean
public RestClient loyaltyRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return RestClient.builder()
.baseUrl(coreBaseUrl)
.requestInterceptor((request, body, execution) -> {
Authentication principal =
new AnonymousAuthenticationToken(
"key", "loyalty-agent-service",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.build();
try {
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
}
} catch (Exception e) {
throw new IllegalStateException("Failed to authorize loyalty API request", e);
}
return execution.execute(request, body);
})
.build();
}
@Bean
public RestClient rewardRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return RestClient.builder()
.baseUrl(coreBaseUrl + "/svc/reward")
.requestInterceptor((request, body, execution) -> {
org.springframework.security.core.Authentication principal =
new org.springframework.security.authentication.AnonymousAuthenticationToken(
"key", "loyalty-agent-service",
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.build();
try {
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
}
} catch (Exception e) {
throw new IllegalStateException("Failed to authorize reward API request", e);
}
return execution.execute(request, body);
})
.build();
}
@Bean
public MemberTierApiClient memberTierApiClient(@Qualifier("loyaltyRestClient") RestClient loyaltyRestClient) {
return createClient(loyaltyRestClient, MemberTierApiClient.class);
}
@Bean
public PointPoolApiClient pointPoolApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
return createClient(rewardRestClient, PointPoolApiClient.class);
}
@Bean
public CampaignApiClient campaignApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
return createClient(rewardRestClient, CampaignApiClient.class);
}
private <T> T createClient(RestClient restClient, Class<T> clientType) {
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(clientType);
}
}

View File

@@ -0,0 +1,24 @@
package dev.sonpx.loyalty.mcp.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class AsyncTimeoutFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, response);
if (request.isAsyncStarted()) {
// Set async timeout to 24 hours (86400000 ms) to prevent SSE connections from dropping
request.getAsyncContext().setTimeout(86400000L);
}
}
}

View File

@@ -0,0 +1,34 @@
package dev.sonpx.loyalty.mcp.config;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.tool.LoyaltyAgentTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class McpServerConfig {
@Bean
public LoyaltyAgentTools loyaltyAgentTools(
MemberTierApiClient memberTierApiClient,
PointPoolApiClient pointPoolApiClient,
CampaignApiClient campaignApiClient,
@org.springframework.beans.factory.annotation.Qualifier("loyaltyRestClient") RestClient restClient) {
return new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, restClient);
}
@Bean
public org.springframework.ai.tool.ToolCallbackProvider loyaltyToolsProvider(
LoyaltyAgentTools loyaltyAgentTools,
CampaignRuleTools campaignRuleTools,
dev.sonpx.loyalty.mcp.tool.reward.CampaignTools campaignTools) {
return org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
.toolObjects(loyaltyAgentTools, campaignRuleTools, campaignTools)
.build();
}
}

View File

@@ -0,0 +1,44 @@
package dev.sonpx.loyalty.mcp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}

View File

@@ -0,0 +1,44 @@
package dev.sonpx.loyalty.mcp.draft;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import org.springframework.stereotype.Component;
@Component
public class DependencyValidator {
public void validateCampaignRequiredFields(CampaignDto campaign) {
if (campaign == null) {
throw new IllegalArgumentException("Campaign draft is empty");
}
if (campaign.getCampaignId() == null || campaign.getCampaignId().isBlank()) {
throw new IllegalArgumentException("Campaign ID is required");
}
if (campaign.getName() == null || campaign.getName().isBlank()) {
throw new IllegalArgumentException("Campaign name is required");
}
}
public void validateRulePrerequisites(DraftSession session, String targetCampaignId, String transactionCode, boolean needsPoolId, String poolId) {
if (targetCampaignId == null || targetCampaignId.isBlank()) {
throw new IllegalArgumentException("Target Campaign ID is required to add a rule");
}
// In a real system, we might check if targetCampaignId matches the one in draft,
// or check the Core API if the campaign exists.
if (transactionCode == null || transactionCode.isBlank()) {
throw new IllegalArgumentException("Transaction Code is required");
}
if (needsPoolId && (poolId == null || poolId.isBlank())) {
throw new IllegalArgumentException("Pool ID is required for the selected rule flow");
}
}
public void validateSopPrerequisites(String poolId) {
if (poolId == null || poolId.isBlank()) {
throw new IllegalArgumentException("Existing Pool ID is required to create a Statement Output Pool");
}
}
}

View File

@@ -0,0 +1,58 @@
package dev.sonpx.loyalty.mcp.draft;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Component
public class DraftSessionManager {
private final Map<String, DraftSession> sessions = new ConcurrentHashMap<>();
private static final Duration TTL = Duration.ofHours(1);
public DraftSession getSession(String conversationId) {
if (conversationId == null) {
throw new IllegalArgumentException("conversationId cannot be null");
}
cleanupExpiredSessions();
return sessions.compute(conversationId, (id, session) -> {
if (session == null || isExpired(session)) {
return new DraftSession(id);
}
session.setLastUpdated(Instant.now());
return session;
});
}
public void updateSession(String conversationId, Consumer<DraftSession> updater) {
if (conversationId == null) {
throw new IllegalArgumentException("conversationId cannot be null");
}
cleanupExpiredSessions();
sessions.compute(conversationId, (id, session) -> {
if (session == null || isExpired(session)) {
session = new DraftSession(id);
}
updater.accept(session);
session.setLastUpdated(Instant.now());
return session;
});
}
public void removeSession(String conversationId) {
sessions.remove(conversationId);
}
private boolean isExpired(DraftSession session) {
return Instant.now().isAfter(session.getLastUpdated().plus(TTL));
}
private void cleanupExpiredSessions() {
sessions.entrySet().removeIf(entry -> isExpired(entry.getValue()));
}
}

View File

@@ -0,0 +1,14 @@
package dev.sonpx.loyalty.mcp.draft.model;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class CampaignDraft {
private CampaignDto campaign = new CampaignDto();
private List<CampaignRuleDto> rules = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.draft.model;
import lombok.Data;
import java.time.Instant;
@Data
public class DraftSession {
private String conversationId;
private Instant lastUpdated;
private CampaignDraft campaignDraft;
private PoolDraft poolDraft;
public DraftSession(String conversationId) {
this.conversationId = conversationId;
this.lastUpdated = Instant.now();
this.campaignDraft = new CampaignDraft();
this.poolDraft = new PoolDraft();
}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.draft.model;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
@Data
public class PoolDraft {
private Map<String, Object> poolDefinition = new HashMap<>();
private Map<String, Object> statementOutputPool = new HashMap<>();
}

View File

@@ -0,0 +1,15 @@
package dev.sonpx.loyalty.mcp.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpResponseWrapper<T> {
private T data;
private Presentation presentation;
}

View File

@@ -0,0 +1,15 @@
package dev.sonpx.loyalty.mcp.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Presentation {
private String type; // e.g. "markdown"
private String content; // The formatted string
}

View File

@@ -0,0 +1,77 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.client.model.attribute.StaticAttributeDto;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
import dev.sonpx.loyalty.mcp.client.model.PointPool;
import org.springframework.ai.tool.annotation.Tool;
import java.util.List;
import org.springframework.core.ParameterizedTypeReference;
public class LoyaltyAgentTools {
private final MemberTierApiClient memberTierApiClient;
private final PointPoolApiClient pointPoolApiClient;
private final CampaignApiClient campaignApiClient;
private final org.springframework.web.client.RestClient restClient;
public LoyaltyAgentTools(MemberTierApiClient memberTierApiClient, PointPoolApiClient pointPoolApiClient, CampaignApiClient campaignApiClient, org.springframework.web.client.RestClient restClient) {
this.memberTierApiClient = memberTierApiClient;
this.pointPoolApiClient = pointPoolApiClient;
this.campaignApiClient = campaignApiClient;
this.restClient = restClient;
}
@Tool(description = "List all available Member Tiers. Always use this to verify a Tier exists before creating a Campaign.")
public List<MemberTier> listMemberTiers() {
return memberTierApiClient.listMemberTiers();
}
@Tool(description = "List all available Point Pools. Always use this to verify a Pool exists and has sufficient balance before creating a Campaign.")
public List<PointPool> listPointPools() {
return pointPoolApiClient.listPointPools();
}
@Tool(description = "Create a new Campaign. ONLY use this when explicitly asked to create or add a new campaign. Do not use this to fetch information about existing campaigns. This requires a valid targetTierId and rewardPoolId, which MUST be verified beforehand using listMemberTiers and listPointPools.")
public Campaign createCampaign(CreateCampaignRequest request) {
String campaignId = request.campaignId();
if (campaignId == null || campaignId.isBlank()) {
campaignId = campaignApiClient.generateCampaignId();
}
Campaign draft = new Campaign(
campaignId,
request.name(),
request.targetTierId(),
request.rewardPoolId(),
null
);
OperationResult<Campaign> result = campaignApiClient.createCampaignDraft(draft);
if (result == null || result.entity() == null) {
String message = result != null ? result.message() : "empty response";
throw new IllegalStateException("Campaign create-draft did not return an entity: " + message);
}
return result.entity();
}
@Tool(description = "List all available Static Attributes. Use this tool when you need to find configurations, properties, or static values like tier types or currencies.")
public List<StaticAttributeDto> listStaticAttributes() {
try {
String url = "/svc/attribute/api/static-attribute/csr/view/list?sort=lastApproveDate,desc&sort=lastUpdateDate,desc&page=0&size=10&status.in=A";
return restClient.get()
.uri(url)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
} catch (Exception e) {
throw new RuntimeException("Failed to fetch static attributes", e);
}
}
}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.tool.model.pool;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record CreatePoolDefinitionRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Pool ID (max 10 chars)")
String poolId,
@JsonPropertyDescription("Pool Name")
String poolName,
@JsonPropertyDescription("Pool Type (e.g. STANDARD, PROMOTIONAL)")
String poolType,
@JsonPropertyDescription("Currency Code")
String currencyCode
) {}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.mcp.tool.model.pool;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record CreateSopRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Statement Output Pool ID")
String sopId,
@JsonPropertyDescription("Reference Pool ID (must exist)")
String poolId,
@JsonPropertyDescription("SOP Name")
String name,
@JsonPropertyDescription("Statement Type")
String statementType
) {}

View File

@@ -0,0 +1,23 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record AddRuleToCampaignRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Target Campaign ID")
String targetCampaignId,
@JsonPropertyDescription("Transaction Code to trigger the rule")
String transactionCode,
@JsonPropertyDescription("Whether this rule flow requires a Pool ID")
boolean needsPoolId,
@JsonPropertyDescription("Pool ID for the rule (if needed)")
String poolId,
@JsonPropertyDescription("Rule details")
CreateCampaignRuleToolRequest ruleDetails
) {}

View File

@@ -0,0 +1,41 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.time.OffsetDateTime;
public record CreateCampaignRuleToolRequest(
@JsonPropertyDescription("ID of the campaign this rule belongs to")
String campaignId,
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
String ruleId,
@JsonPropertyDescription("Name of the rule")
String ruleName,
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
String ruleType,
@JsonPropertyDescription("Description of the rule")
String description,
@JsonPropertyDescription("Effective start date of the rule")
OffsetDateTime effectiveFrom,
@JsonPropertyDescription("Effective end date of the rule")
OffsetDateTime effectiveTo,
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
String poolId,
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
FormulaOneConfig formulaOne
) {
public record FormulaOneConfig(
@JsonPropertyDescription("Amount of points awarded per transaction amount")
Double awardPerTxnAmt,
@JsonPropertyDescription("Required transaction amount to receive the award")
Double txnAmt
) {}
}

View File

@@ -0,0 +1,11 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ListCampaignRuleToolRequest(
String campaignId,
Integer page,
Integer size
) {
}

View File

@@ -0,0 +1,8 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record SubmitCampaignDraftRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId
) {}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record UpdateCampaignDraftRequest(
@JsonPropertyDescription("Conversation ID from the agent")
String conversationId,
@JsonPropertyDescription("Campaign ID")
String campaignId,
@JsonPropertyDescription("Campaign Name")
String name,
@JsonPropertyDescription("Target Tier ID")
String targetTierId
) {}

View File

@@ -0,0 +1,34 @@
package dev.sonpx.loyalty.mcp.tool.model.reward;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.time.OffsetDateTime;
public record UpdateCampaignRuleToolRequest(
@JsonPropertyDescription("ID of the campaign this rule belongs to")
String campaignId,
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
String ruleId,
@JsonPropertyDescription("Name of the rule")
String ruleName,
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
String ruleType,
@JsonPropertyDescription("Description of the rule")
String description,
@JsonPropertyDescription("Effective start date of the rule")
OffsetDateTime effectiveFrom,
@JsonPropertyDescription("Effective end date of the rule")
OffsetDateTime effectiveTo,
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
String poolId,
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
CreateCampaignRuleToolRequest.FormulaOneConfig formulaOne
) {
}

View File

@@ -0,0 +1,86 @@
package dev.sonpx.loyalty.mcp.tool.pool;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Component
public class PointPoolTools {
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
private final RestClient restClient;
public PointPoolTools(DraftSessionManager draftSessionManager, DependencyValidator dependencyValidator, @Qualifier("rewardRestClient") RestClient restClient) {
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
this.restClient = restClient;
}
@Tool(description = "Create a Pool Definition. This builds a PoolDefinitionDto and submits it via Core API.")
public Map<String, Object> createPoolDefinition(CreatePoolDefinitionRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Store in draft
draftSessionManager.updateSession(request.conversationId(), session -> {
Map<String, Object> poolDef = session.getPoolDraft().getPoolDefinition();
poolDef.put("poolId", request.poolId());
poolDef.put("poolName", request.poolName());
poolDef.put("poolType", request.poolType());
poolDef.put("currencyCode", request.currencyCode());
});
// Submit via Core API
try {
return restClient.post()
.uri("/api/pool-definition/csr/create-draft")
.body(request)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
e.printStackTrace();
return Map.of("error", "Failed to create pool definition draft via Core API: " + e.getMessage());
}
}
@Tool(description = "Create a Statement Output Pool (SOP). This builds a StatementOutputPoolDto and requires an existing Pool ID.")
public Map<String, Object> createSop(CreateSopRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Validate prerequisite: existing Pool ID
dependencyValidator.validateSopPrerequisites(request.poolId());
// Store in draft
draftSessionManager.updateSession(request.conversationId(), session -> {
Map<String, Object> sop = session.getPoolDraft().getStatementOutputPool();
sop.put("sopId", request.sopId());
sop.put("poolId", request.poolId());
sop.put("name", request.name());
sop.put("statementType", request.statementType());
});
// Submit via Core API
try {
return restClient.post()
.uri("/api/sop/csr/create-draft")
.body(request)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
e.printStackTrace();
return Map.of("error", "Failed to create Statement Output Pool draft via Core API: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,77 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignFormulaOneDto;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
@Component
public class CampaignRuleMapper {
public CampaignRuleDto toDto(CreateCampaignRuleToolRequest request) {
CampaignRuleDto dto = new CampaignRuleDto();
dto.setCampaignId(toReferenceData(request.campaignId()));
dto.setRuleId(request.ruleId());
dto.setRuleName(request.ruleName());
if (request.ruleType() != null) {
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
}
dto.setDescription(request.description());
dto.setEffectiveFrom(request.effectiveFrom());
dto.setEffectiveTo(request.effectiveTo());
dto.setPoolId(toReferenceData(request.poolId()));
if (request.formulaOne() != null) {
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
dto.setCampaignFormulaOne(formulaOne);
dto.setFormulaSequence(new ArrayList<>());
dto.getFormulaSequence().add("1");
}
return dto;
}
public CampaignRuleDto toDto(UpdateCampaignRuleToolRequest request) {
CampaignRuleDto dto = new CampaignRuleDto();
dto.setCampaignId(toReferenceData(request.campaignId()));
dto.setRuleId(request.ruleId());
dto.setRuleName(request.ruleName());
if (request.ruleType() != null) {
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
}
dto.setDescription(request.description());
dto.setEffectiveFrom(request.effectiveFrom());
dto.setEffectiveTo(request.effectiveTo());
dto.setPoolId(toReferenceData(request.poolId()));
if (request.formulaOne() != null) {
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
dto.setCampaignFormulaOne(formulaOne);
dto.setFormulaSequence(new ArrayList<>());
dto.getFormulaSequence().add("1");
}
return dto;
}
private ReferenceData toReferenceData(String code) {
if (code == null) {
return null;
}
ReferenceData ref = new ReferenceData();
ref.setCode(code);
return ref;
}
}

View File

@@ -0,0 +1,211 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.client.model.reward.OperationResultCampaignRuleDto;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.ListCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
import dev.sonpx.loyalty.mcp.model.Presentation;
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
@Component
public class CampaignRuleTools {
private static final Logger log = LoggerFactory.getLogger(CampaignRuleTools.class);
private final CampaignRuleMapper campaignRuleMapper;
private final RestClient rewardRestClient;
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
public CampaignRuleTools(
CampaignRuleMapper campaignRuleMapper,
@Qualifier("rewardRestClient") RestClient rewardRestClient,
DraftSessionManager draftSessionManager,
DependencyValidator dependencyValidator) {
this.campaignRuleMapper = campaignRuleMapper;
this.rewardRestClient = rewardRestClient;
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
}
@Tool(description = "Add Rule to local Campaign Draft. Validates prerequisites (Transaction Code, Pool ID) before adding.")
public Map<String, Object> addRuleToCampaign(AddRuleToCampaignRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
// Validate dependencies
dependencyValidator.validateRulePrerequisites(
draftSessionManager.getSession(request.conversationId()),
request.targetCampaignId(),
request.transactionCode(),
request.needsPoolId(),
request.poolId()
);
// Update local draft
draftSessionManager.updateSession(request.conversationId(), session -> {
CampaignRuleDto rule = campaignRuleMapper.toDto(request.ruleDetails());
dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData ref = new dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData();
ref.setCode(request.targetCampaignId());
rule.setCampaignId(ref);
session.getCampaignDraft().getRules().add(rule);
});
return Map.of("status", "Rule added to Campaign draft successfully", "ruleId", request.ruleDetails().ruleId());
}
@Tool(description = "Create a new Campaign Rule for an existing Campaign. Supports basic properties and Formula One configuration for points per transaction amount.")
public OperationResultCampaignRuleDto createCampaignRule(CreateCampaignRuleToolRequest request) {
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
try {
return rewardRestClient.post()
.uri("/api/campaign-rule/csr/create")
.body(dto)
.retrieve()
.body(OperationResultCampaignRuleDto.class);
} catch (Exception e) {
log.error("Failed to create Campaign Rule", e);
throw new RuntimeException("Failed to create Campaign Rule: " + e.getMessage(), e);
}
}
@Tool(description = "Edit an existing Campaign Rule. You must provide all required fields (campaignId, ruleId, ruleName, etc) as this will update the entire rule configuration.")
public OperationResultCampaignRuleDto editCampaignRule(UpdateCampaignRuleToolRequest request) {
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
try {
return rewardRestClient.patch()
.uri("/api/campaign-rule/csr/edit")
.body(dto)
.retrieve()
.body(OperationResultCampaignRuleDto.class);
} catch (Exception e) {
log.error("Failed to edit Campaign Rule", e);
throw new RuntimeException("Failed to edit Campaign Rule: " + e.getMessage(), e);
}
}
@Tool(description = "List Campaign Rules, optionally filtered by campaignId. Use this tool to get a list of campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRules(
@org.springframework.lang.Nullable String campaignId,
@org.springframework.lang.Nullable Integer page,
@org.springframework.lang.Nullable Integer size) {
int pageNum = page != null ? page : 0;
int sizeNum = size != null ? size : 20;
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> {
uriBuilder.path("/api/campaign-rule/csr/list")
.queryParam("page", pageNum)
.queryParam("size", sizeNum);
if (campaignId != null && !campaignId.isEmpty()) {
uriBuilder.queryParam("campaignId.equals", campaignId);
}
return uriBuilder.build();
})
.retrieve()
.body(String.class);
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
// Try to map it manually
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
try {
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
} catch (Exception parseEx) {
System.out.println("Failed to parse as array. Trying to extract from pageable format...");
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
} else if (root.has("data")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
}
throw parseEx;
}
} catch (Exception e) {
log.error("Failed to list Campaign Rules", e);
throw new RuntimeException("Failed to list Campaign Rules: " + e.getMessage(), e);
}
}
@Tool(description = "List Campaign Rules by Campaign Name. It finds the campaign by name and returns its rules. Use this tool when the user provides a campaign name and asks for its campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRulesByCampaignName(String campaignName) {
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/campaign/csr/list")
.queryParam("name.contains", campaignName)
.queryParam("page", 0)
.queryParam("size", 1)
.build()
)
.retrieve()
.body(String.class);
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[] campaigns = null;
try {
campaigns = mapper.readValue(jsonResponse, dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} catch (Exception parseEx) {
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
campaigns = mapper.treeToValue(root.get("content"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} else if (root.has("data")) {
campaigns = mapper.treeToValue(root.get("data"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
} else {
throw parseEx;
}
}
if (campaigns == null || campaigns.length == 0) {
return wrapCampaignRules(List.of());
}
String campaignId = campaigns[0].getCampaignId();
return listCampaignRules(campaignId, 0, 100);
} catch (Exception e) {
log.error("Failed to list Campaign Rules by Campaign Name", e);
throw new RuntimeException("Failed to list Campaign Rules by Campaign Name: " + e.getMessage(), e);
}
}
private McpResponseWrapper<List<CampaignRuleDto>> wrapCampaignRules(List<CampaignRuleDto> data) {
StringBuilder markdown = new StringBuilder();
if (data.isEmpty()) {
markdown.append("No campaign rules found.");
} else {
for (CampaignRuleDto r : data) {
markdown.append(MarkdownGenerator.generateCampaignRuleMarkdown(r)).append("\n");
}
}
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
}
}

View File

@@ -0,0 +1,150 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
import dev.sonpx.loyalty.mcp.model.Presentation;
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component
public class CampaignTools {
private static final Logger log = LoggerFactory.getLogger(CampaignTools.class);
private final RestClient rewardRestClient;
private final DraftSessionManager draftSessionManager;
private final DependencyValidator dependencyValidator;
public CampaignTools(
@Qualifier("rewardRestClient") RestClient rewardRestClient,
DraftSessionManager draftSessionManager,
DependencyValidator dependencyValidator) {
this.rewardRestClient = rewardRestClient;
this.draftSessionManager = draftSessionManager;
this.dependencyValidator = dependencyValidator;
}
@Tool(description = "List or Search Campaigns, optionally filtered by campaignId, name, or status. ALWAYS use this tool when the user asks for information, details, or status of an existing campaign. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
public McpResponseWrapper<List<CampaignDto>> listCampaigns(
@org.springframework.lang.Nullable String campaignId,
@org.springframework.lang.Nullable String name,
@org.springframework.lang.Nullable String status,
@org.springframework.lang.Nullable Integer page,
@org.springframework.lang.Nullable Integer size) {
int pageNum = page != null ? page : 0;
int sizeNum = size != null ? size : 20;
try {
String jsonResponse = rewardRestClient.get()
.uri(uriBuilder -> {
uriBuilder.path("/api/campaign/csr/list")
.queryParam("page", pageNum)
.queryParam("size", sizeNum);
if (campaignId != null && !campaignId.isEmpty()) {
uriBuilder.queryParam("campaignId.equals", campaignId);
}
if (name != null && !name.isEmpty()) {
uriBuilder.queryParam("name.contains", name);
}
if (status != null && !status.isEmpty()) {
uriBuilder.queryParam("status.equals", status);
}
return uriBuilder.build();
})
.retrieve()
.body(String.class);
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
CampaignDto[] campaigns = null;
try {
campaigns = mapper.readValue(jsonResponse, CampaignDto[].class);
} catch (Exception parseEx) {
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
campaigns = mapper.treeToValue(root.get("content"), CampaignDto[].class);
} else if (root.has("data")) {
campaigns = mapper.treeToValue(root.get("data"), CampaignDto[].class);
} else {
throw parseEx;
}
}
List<CampaignDto> data = campaigns != null ? List.of(campaigns) : List.of();
StringBuilder markdown = new StringBuilder();
if (data.isEmpty()) {
markdown.append("No campaigns found.");
} else {
for (CampaignDto c : data) {
markdown.append(MarkdownGenerator.generateCampaignMarkdown(c)).append("\n");
}
}
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
} catch (Exception e) {
log.error("Failed to list Campaigns", e);
throw new RuntimeException("Failed to list Campaigns: " + e.getMessage(), e);
}
}
@Tool(description = "Update local Campaign Draft.")
public Map<String, Object> updateCampaignDraft(UpdateCampaignDraftRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
draftSessionManager.updateSession(request.conversationId(), session -> {
CampaignDto campaign = session.getCampaignDraft().getCampaign();
if (request.campaignId() != null) campaign.setCampaignId(request.campaignId());
if (request.name() != null) campaign.setName(request.name());
if (request.targetTierId() != null) campaign.setTargetTierId(request.targetTierId());
});
return Map.of("status", "Campaign draft updated successfully", "campaignId", request.campaignId());
}
@Tool(description = "Submit the completed CampaignDraft to Core API.")
public Map<String, Object> submitCampaignDraft(SubmitCampaignDraftRequest request) {
if (request.conversationId() == null || request.conversationId().isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
DraftSession session = draftSessionManager.getSession(request.conversationId());
CampaignDto campaign = session.getCampaignDraft().getCampaign();
// Validate
dependencyValidator.validateCampaignRequiredFields(campaign);
// Submit via Core API
try {
return rewardRestClient.post()
.uri("/api/campaign/csr/create-draft")
.body(campaign)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
} catch (Exception e) {
log.error("Failed to submit Campaign draft via Core API", e);
return Map.of("error", "Failed to submit Campaign draft via Core API: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.sonpx.loyalty.mcp.util;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
public class MarkdownGenerator {
public static String generateCampaignMarkdown(CampaignDto campaign) {
if (campaign == null) {
return "No campaign data available.";
}
StringBuilder sb = new StringBuilder();
sb.append("### 🏆 Campaign Details\n\n");
sb.append("| Field | Value |\n");
sb.append("| :--- | :--- |\n");
sb.append("| **Campaign ID** | ").append(campaign.getCampaignId()).append(" |\n");
sb.append("| **Name** | ").append(campaign.getName()).append(" |\n");
sb.append("| **Status** | ").append(campaign.getStatus() != null ? campaign.getStatus() : "N/A").append(" |\n");
sb.append("| **Target Tier ID** | ").append(campaign.getTargetTierId() != null ? campaign.getTargetTierId() : "N/A").append(" |\n");
sb.append("| **Reward Pool ID** | ").append(campaign.getRewardPoolId() != null ? campaign.getRewardPoolId() : "N/A").append(" |\n");
if (campaign.getDescription() != null) {
sb.append("| **Description** | ").append(campaign.getDescription()).append(" |\n");
}
if (campaign.getExtraData() != null && !campaign.getExtraData().isEmpty()) {
for (java.util.Map.Entry<String, Object> entry : campaign.getExtraData().entrySet()) {
String key = entry.getKey();
String formattedKey = key.substring(0, 1).toUpperCase() + key.substring(1).replaceAll("([A-Z])", " $1");
sb.append("| **").append(formattedKey).append("** | ").append(entry.getValue()).append(" |\n");
}
}
return sb.toString();
}
public static String generateCampaignRuleMarkdown(CampaignRuleDto rule) {
if (rule == null) {
return "No campaign rule data available.";
}
String campaignCodeStr = (rule.getCampaignId() != null && rule.getCampaignId().getCode() != null) ? rule.getCampaignId().getCode() : "N/A";
String poolCodeStr = (rule.getPoolId() != null && rule.getPoolId().getCode() != null) ? rule.getPoolId().getCode() : "N/A";
StringBuilder sb = new StringBuilder();
sb.append("### 📜 Campaign Rule Details\n\n");
sb.append("| Field | Value |\n");
sb.append("| :--- | :--- |\n");
sb.append("| **Rule ID** | ").append(rule.getRuleId()).append(" |\n");
sb.append("| **Rule Name** | ").append(rule.getRuleName()).append(" |\n");
sb.append("| **Rule Type** | ").append(rule.getRuleType() != null ? rule.getRuleType().getValue() : "N/A").append(" |\n");
sb.append("| **Campaign ID** | ").append(campaignCodeStr).append(" |\n");
sb.append("| **Pool ID** | ").append(poolCodeStr).append(" |\n");
sb.append("| **Description** | ").append(rule.getDescription() != null ? rule.getDescription() : "N/A").append(" |\n");
sb.append("| **Effective From** | ").append(rule.getEffectiveFrom() != null ? rule.getEffectiveFrom().toString() : "N/A").append(" |\n");
sb.append("| **Effective To** | ").append(rule.getEffectiveTo() != null ? rule.getEffectiveTo().toString() : "N/A").append(" |\n");
return sb.toString();
}
}

View File

@@ -0,0 +1,37 @@
server:
port: 9331
shutdown: immediate
spring:
mvc:
async:
request-timeout: 3600000
application:
name: loyalty-mcp-server
ai:
mcp:
server:
sse-endpoint: /sse
sse-message-endpoint: /message
security:
oauth2:
client:
registration:
keycloak:
client-id: ols-cli
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
authorization-grant-type: client_credentials
provider:
keycloak:
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
loyalty:
core:
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
logging:
level:
root: warn
org.springframework.ai: INFO
org.springframework.web: INFO
dev.sonpx.loyalty: DEBUG

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
package dev.sonpx.loyalty.mcp.test;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
public class TestParsing {
public static void main(String[] args) throws Exception {
String jsonResponse = new String(Files.readAllBytes(Paths.get("rules.json")));
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
System.out.println("Transformed JSON: " + jsonResponse.substring(0, Math.min(200, jsonResponse.length())));
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
try {
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
System.out.println("Parsed directly as array, length: " + rules.length);
} catch (Exception parseEx) {
System.out.println("Failed to parse as array: " + parseEx.getMessage());
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
if (root.has("content")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
System.out.println("Parsed from content, length: " + rules.length);
} else if (root.has("data")) {
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
System.out.println("Parsed from data, length: " + rules.length);
} else {
throw parseEx;
}
}
}
}

View File

@@ -0,0 +1,93 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
import dev.sonpx.loyalty.mcp.client.model.Campaign;
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class LoyaltyAgentToolsRealApiTest {
private MockRestServiceServer loyaltyServer;
private MockRestServiceServer rewardServer;
private LoyaltyAgentTools tools;
@BeforeEach
void setUp() {
RestClient.Builder loyaltyRestClientBuilder = RestClient.builder().baseUrl("http://core.test");
loyaltyServer = MockRestServiceServer.bindTo(loyaltyRestClientBuilder).build();
RestClient loyaltyRestClient = loyaltyRestClientBuilder.build();
RestClient.Builder rewardRestClientBuilder = RestClient.builder().baseUrl("http://reward.test");
rewardServer = MockRestServiceServer.bindTo(rewardRestClientBuilder).build();
RestClient rewardRestClient = rewardRestClientBuilder.build();
MemberTierApiClient memberTierApiClient = createClient(loyaltyRestClient, MemberTierApiClient.class);
PointPoolApiClient pointPoolApiClient = createClient(rewardRestClient, PointPoolApiClient.class);
CampaignApiClient campaignApiClient = createClient(rewardRestClient, CampaignApiClient.class);
tools = new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, loyaltyRestClient);
}
@Test
void listMemberTiersCallsRealEndpoint() {
loyaltyServer.expect(requestTo("http://core.test/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A"))
.andExpect(method(GET))
.andRespond(withSuccess("[{\"key\":\"TIER_GOLD\",\"code\":\"GOLD\",\"name\":\"Gold\"}]", MediaType.APPLICATION_JSON));
MemberTier tier = tools.listMemberTiers().getFirst();
assertEquals("TIER_GOLD", tier.tierId());
assertEquals("Gold", tier.tierName());
loyaltyServer.verify();
}
@Test
void listPointPoolsCallsRealEndpoint() {
rewardServer.expect(requestTo("http://reward.test/api/pool-definition/all"))
.andExpect(method(GET))
.andRespond(withSuccess("[{\"poolId\":\"POOL_1\",\"poolName\":\"Main Pool\"}]", MediaType.APPLICATION_JSON));
assertEquals("POOL_1", tools.listPointPools().getFirst().poolId());
rewardServer.verify();
}
@Test
void createCampaignGeneratesIdAndUnwrapsOperationResultEntity() {
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/id/generate"))
.andExpect(method(GET))
.andRespond(withSuccess("CAMP_1", MediaType.TEXT_PLAIN));
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess(
"{\"message\":\"OK\",\"entity\":{\"campaignId\":\"CAMP_1\",\"name\":\"Summer\",\"status\":\"DRAFT\"}}",
MediaType.APPLICATION_JSON));
Campaign campaign = tools.createCampaign(new CreateCampaignRequest("Summer", "TIER_GOLD", "POOL_1"));
assertEquals("CAMP_1", campaign.campaignId());
assertEquals("DRAFT", campaign.status());
rewardServer.verify();
}
private static <T> T createClient(RestClient restClient, Class<T> clientType) {
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
return factory.createClient(clientType);
}
}

View File

@@ -0,0 +1,112 @@
package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
import dev.sonpx.loyalty.mcp.tool.pool.PointPoolTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleMapper;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
import dev.sonpx.loyalty.mcp.tool.reward.CampaignTools;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.time.OffsetDateTime;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import static org.springframework.http.HttpMethod.POST;
class McpOrchestratorTest {
private DraftSessionManager sessionManager;
private DependencyValidator dependencyValidator;
private MockRestServiceServer mockServer;
private PointPoolTools pointPoolTools;
private CampaignTools campaignTools;
private CampaignRuleTools campaignRuleTools;
@BeforeEach
void setUp() {
sessionManager = new DraftSessionManager();
dependencyValidator = new DependencyValidator();
RestClient.Builder restClientBuilder = RestClient.builder().baseUrl("http://reward.test");
mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
RestClient restClient = restClientBuilder.build();
pointPoolTools = new PointPoolTools(sessionManager, dependencyValidator, restClient);
campaignTools = new CampaignTools(restClient, sessionManager, dependencyValidator);
campaignRuleTools = new CampaignRuleTools(new CampaignRuleMapper(), restClient, sessionManager, dependencyValidator);
}
@Test
void testFullDependencyGraphAndErrorPropagation() {
String convId = "test-conv-123";
mockServer.expect(requestTo("http://reward.test/api/pool-definition/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"poolId\":\"POOL-1\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/sop/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"sopId\":\"SOP-1\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withSuccess("{\"campaignId\":\"CAMP-1\",\"status\":\"DRAFT_CREATED\"}", MediaType.APPLICATION_JSON));
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
.andExpect(method(POST))
.andRespond(withStatus(HttpStatus.BAD_REQUEST).body("400 Bad Request"));
// 1. Create Pool Definition
CreatePoolDefinitionRequest poolReq = new CreatePoolDefinitionRequest(convId, "POOL-1", "Test Pool", "STANDARD", "USD");
Map<String, Object> poolRes = pointPoolTools.createPoolDefinition(poolReq);
assertEquals("POOL-1", poolRes.get("poolId"));
// 2. Create SOP
CreateSopRequest sopReq = new CreateSopRequest(convId, "SOP-1", "POOL-1", "Test SOP", "ACCRUAL");
Map<String, Object> sopRes = pointPoolTools.createSop(sopReq);
assertEquals("SOP-1", sopRes.get("sopId"));
// 3. Create Campaign Draft
UpdateCampaignDraftRequest updateCampReq = new UpdateCampaignDraftRequest(convId, "CAMP-1", "My Campaign", "TIER-1");
campaignTools.updateCampaignDraft(updateCampReq);
// 4. Add Rule to Campaign (Valid)
CreateCampaignRuleToolRequest ruleDetails = new CreateCampaignRuleToolRequest("CAMP-1", "RULE-1", "My Rule", "AWD", "Desc", OffsetDateTime.now(), OffsetDateTime.now(), "POOL-1", null);
AddRuleToCampaignRequest addRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-1", true, "POOL-1", ruleDetails);
Map<String, Object> ruleRes = campaignRuleTools.addRuleToCampaign(addRuleReq);
assertEquals("RULE-1", ruleRes.get("ruleId"));
// 5. Submit Campaign Draft
SubmitCampaignDraftRequest submitCampReq = new SubmitCampaignDraftRequest(convId);
Map<String, Object> submitCampRes = campaignTools.submitCampaignDraft(submitCampReq);
assertEquals("CAMP-1", submitCampRes.get("campaignId"));
// Test Error Propagation: Missing Pool ID for SOP
CreateSopRequest badSopReq = new CreateSopRequest(convId, "SOP-2", "", "Bad SOP", "ACCRUAL");
assertThrows(IllegalArgumentException.class, () -> pointPoolTools.createSop(badSopReq));
// Test Error Propagation: Add Rule missing required Pool ID
AddRuleToCampaignRequest badRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-2", true, "", ruleDetails);
assertThrows(IllegalArgumentException.class, () -> campaignRuleTools.addRuleToCampaign(badRuleReq));
// Test Error propagation from Core API
Map<String, Object> errorRes = campaignTools.submitCampaignDraft(submitCampReq);
assertTrue(((String)errorRes.get("error")).contains("400 Bad Request"));
mockServer.verify();
}
}

View File

@@ -0,0 +1,83 @@
package dev.sonpx.loyalty.mcp.tool.reward;
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CampaignRuleMapperTest {
private CampaignRuleMapper mapper;
@BeforeEach
void setUp() {
mapper = new CampaignRuleMapper();
}
@Test
void testToDto_CreateRequest_WithFormulaOne() {
CreateCampaignRuleToolRequest request = new CreateCampaignRuleToolRequest(
"CAMPAIGN-123",
"RULE-1",
"My Rule",
"AWD",
"Description",
OffsetDateTime.now(),
OffsetDateTime.now().plusDays(10),
"POOL-ABC",
new CreateCampaignRuleToolRequest.FormulaOneConfig(10.0, 100.0)
);
CampaignRuleDto dto = mapper.toDto(request);
assertNotNull(dto);
assertNotNull(dto.getCampaignId());
assertEquals("CAMPAIGN-123", dto.getCampaignId().getCode());
assertEquals("RULE-1", dto.getRuleId());
assertEquals("My Rule", dto.getRuleName());
assertEquals(CampaignRuleDto.RuleTypeEnum.AWD, dto.getRuleType());
assertNotNull(dto.getPoolId());
assertEquals("POOL-ABC", dto.getPoolId().getCode());
assertNotNull(dto.getCampaignFormulaOne());
assertEquals(10.0, dto.getCampaignFormulaOne().getAwardPerTxnAmt());
assertEquals(100.0, dto.getCampaignFormulaOne().getTxnAmt());
assertEquals(1, dto.getFormulaSequence().size());
assertEquals("1", dto.getFormulaSequence().get(0));
}
@Test
void testToDto_UpdateRequest_WithoutFormulaOne() {
UpdateCampaignRuleToolRequest request = new UpdateCampaignRuleToolRequest(
"CAMPAIGN-456",
"RULE-2",
"My Updated Rule",
"RED",
"Updated Description",
null,
null,
"POOL-XYZ",
null
);
CampaignRuleDto dto = mapper.toDto(request);
assertNotNull(dto);
assertNotNull(dto.getCampaignId());
assertEquals("CAMPAIGN-456", dto.getCampaignId().getCode());
assertEquals("RULE-2", dto.getRuleId());
assertEquals("My Updated Rule", dto.getRuleName());
assertEquals(CampaignRuleDto.RuleTypeEnum.RED, dto.getRuleType());
assertNotNull(dto.getPoolId());
assertEquals("POOL-XYZ", dto.getPoolId().getCode());
assertNull(dto.getCampaignFormulaOne());
assertTrue(dto.getFormulaSequence() == null || dto.getFormulaSequence().isEmpty());
}
}