Merge remote-tracking branch 'origin/main'
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
Boram
2026-08-11 16:23:03 +09:00
130 changed files with 2463 additions and 586 deletions

View File

@@ -28,7 +28,7 @@ jobs:
run: | run: |
echo "Copying latest code to /app (Host Volume) and removing stale files..." echo "Copying latest code to /app (Host Volume) and removing stale files..."
if command -v rsync &> /dev/null; then if command -v rsync &> /dev/null; then
rsync -a --delete --exclude='.git' . /app/ rsync -a --delete --exclude='.git' --exclude='bin' --exclude='act_runner_data' . /app/
else else
rm -rf /app/dap-* /app/src /app/build.gradle /app/settings.gradle rm -rf /app/dap-* /app/src /app/build.gradle /app/settings.gradle
cp -a . /app/ cp -a . /app/
@@ -61,7 +61,8 @@ jobs:
docker system prune -f docker system prune -f
ACTIVE_PROFILE=dev docker compose up -d --build --remove-orphans gateway redis mci-mock dozzle was-sms was-oth ACTIVE_PROFILE=dev docker compose up -d --build --remove-orphans gateway redis mci-mock dozzle was-sms was-oth
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 자동 소거 청소! # 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 및 빌드 캐시 자동 소거 청소!
docker image prune -f docker image prune -a -f
docker builder prune -a -f
echo "CI/CD Deploy Success!" echo "CI/CD Deploy Success!"

View File

@@ -1,31 +1,50 @@
# 1. ??<3F><><EFBFBD>???<3F> (JDK 21) # 1. 빌드 환경 (JDK 21)
# -----------------------------------------------------------------------------
# 외부 인터넷이 차단된 내부망에서는 Docker Hub 대신 사내 Container Registry의
# Java 21 이미지를 사용합니다. 인프라 담당자에게 이미지의 전체 경로와 태그를
# 받은 후 아래 FROM 행만 교체합니다.
#
# 예: FROM registry.shinhanlife.co.kr/base/openjdk:21 AS builder
#
# 이 단계는 Gradle 빌드와 Java 컴파일을 수행하므로 반드시 JDK 21 이미지여야 합니다.
# 사내 이미지가 실제로 JDK 21인지 다음 명령으로 확인합니다.
# docker run --rm <사내-JDK-이미지> java -version
FROM eclipse-temurin:21-jdk-alpine AS builder FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app WORKDIR /app
# Gradle ??<3F>띁?? ???<3F><> 蹂듭<E8B982>? # Gradle Wrapper와 소스 파일을 복사합니다.
COPY gradlew . COPY gradlew .
COPY gradle gradle COPY gradle gradle
COPY build.gradle settings.gradle ./ COPY build.gradle settings.gradle ./
COPY src src COPY src src
# 沅뚰<EFBFBD>??<3F>€??<3F>???<3F><><EFBFBD>?(???<3F><>????<3F>쇅) # Wrapper 실행 권한을 부여하고 테스트를 제외한 빌드를 수행합니다.
RUN chmod +x gradlew RUN chmod +x gradlew
RUN ./gradlew clean build -x test RUN ./gradlew clean build -x test
# 2. ??<3F>뻾 ??<3F> (JRE 21) # 2. 실행 환경 (JRE 21)
# -----------------------------------------------------------------------------
# 실행 단계는 JRE 21 이미지가 가장 가볍지만, 사내에서 JDK 21 이미지만 제공하는
# 경우에는 동일한 JDK 21 이미지를 사용해도 정상 동작합니다.
#
# 예: FROM registry.shinhanlife.co.kr/base/openjre:21
# 예: FROM registry.shinhanlife.co.kr/base/openjdk:21
#
# 사내 제공 이미지의 기반 OS를 확인합니다.
# - Alpine 기반: 아래 apk 명령을 그대로 사용합니다.
# - Ubuntu/Debian 기반: apk 대신 apt-get update && apt-get install -y tzdata를 사용합니다.
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre-alpine
WORKDIR /app WORKDIR /app
# ???꾩<>???<3F>젙 (??<3F>뎅 ??<3F>컙) # 서울 시간대를 설정합니다.
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul ENV TZ=Asia/Seoul
# ??<3F><><EFBFBD>??JAR ???<3F><> 蹂듭<E8B982>? # 빌드 단계에서 생성한 애플리케이션 JAR를 복사합니다.
COPY --from=builder /app/build/libs/*.jar app.jar COPY --from=builder /app/build/libs/*.jar app.jar
# 湲곕???????몄텧 # 애플리케이션 포트를 노출합니다.
EXPOSE 8081 EXPOSE 8081
# ?<3F>⑦???<3F><>???<3F>뻾 ??JAR ??<3F> # 애플리케이션을 실행합니다.
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -41,9 +41,9 @@ Gateway는 외부 MCP 진입점과 Tool 등록·라우팅을 담당하고, 실
| 항목 | 기준 | | 항목 | 기준 |
|---|---| |---|---|
| Java | 21 | | Java | 21 |
| Spring Boot | 4.0.5 | | Spring Boot | 3.5.11 |
| Gradle | 8.14.3 (Wrapper) | | Gradle | 8.14.3 (Wrapper) |
| Spring AI BOM | 2.0.0 | | Spring AI BOM | 1.1.8 |
| 기본 프로필 | `local` | | 기본 프로필 | `local` |
로컬 환경에서는 민감한 값은 환경 변수로만 주입합니다. 로컬 환경에서는 민감한 값은 환경 변수로만 주입합니다.
@@ -144,7 +144,7 @@ UI에서 사용하는 Tailwind CSS와 Chart.js는 `dap-gateway/src/main/resource
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "tools/call", "method": "tools/call",
"params": { "params": {
"name": "oth.cmm.claim.search", "name": "oth_cmm_claim_search",
"arguments": { "arguments": {
"claimNo": "CLM2026070100120" "claimNo": "CLM2026070100120"
} }
@@ -170,8 +170,8 @@ Agent나 외부 클라이언트의 표준 MCP 진입은 Gateway를 사용합니
Tool 함수명은 아래 4단계 규칙을 사용합니다. Tool 함수명은 아래 4단계 규칙을 사용합니다.
```text ```text
pod.domain.service.action pod_domain_service_action
예: oth.cmm.claim.search 예: oth_cmm_claim_search
``` ```
- `pod`: Tool Pod 식별자 (`oth`, `sms` 등) - `pod`: Tool Pod 식별자 (`oth`, `sms` 등)
@@ -234,7 +234,7 @@ https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
배포 전에는 다음을 확인합니다. 배포 전에는 다음을 확인합니다.
- Tool 이름의 전역 중복 여부와 `pod.domain.service.action` 규칙(아래 확인 사항 반영 후) - Tool 이름의 전역 중복 여부와 `pod_domain_service_action` 규칙(아래 확인 사항 반영 후)
- Request/Response Schema 및 실제 예제 JSON - Request/Response Schema 및 실제 예제 JSON
- Tool Pod 단위 테스트와 Gateway 경유 호출 - Tool Pod 단위 테스트와 Gateway 경유 호출
- MCI/EAI 오류 코드의 사용자용 응답 매핑 - MCI/EAI 오류 코드의 사용자용 응답 매핑
@@ -266,6 +266,6 @@ https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니다. Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니다.
- `validateMcpToolNames``dap-was-*` Tool Pod를 탐색하여 이름 규칙과 전역 중복을 검사합니다. - `validateMcpToolNames``dap-was-*` Tool Pod를 탐색하여 이름 규칙과 전역 중복을 검사합니다.
- Tool Scaffold는 `dap-was-sms`처럼 선택한 Pod 이름을 Tool 함수명 첫 번째 구간에 반영합니다. 예: `sms.cmm.notification.send` - Tool Scaffold는 `dap-was-sms`처럼 선택한 Pod 이름을 Tool 함수명 첫 번째 구간에 반영합니다. 예: `sms_cmm_notification_send`
- Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다. - Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다.
- Tool Source Update 기능은 `dap-was-*` 아래의 `*UseCase.java`를 검색합니다. - Tool Source Update 기능은 `dap-was-*` 아래의 `*UseCase.java`를 검색합니다.

View File

@@ -1,60 +1,88 @@
// 루트 Gradle 설정: 모든 Gateway/Tool Pod 모듈이 공유하는 빌드 기준을 정의합니다.
plugins { plugins {
// Java 컴파일, 테스트, JAR 생성 기능을 제공합니다.
id 'java' id 'java'
id 'org.springframework.boot' version '4.0.5' apply false
// 하위 실행 모듈에서 bootRun/bootJar를 사용하기 위한 Spring Boot 플러그인입니다.
// 루트 프로젝트에는 적용하지 않으므로 apply false를 사용합니다.
id 'org.springframework.boot' version '3.5.11' apply false
// Spring Boot / Spring AI BOM에 정의된 라이브러리 버전을 일관되게 적용합니다.
id 'io.spring.dependency-management' version '1.1.6' apply false id 'io.spring.dependency-management' version '1.1.6' apply false
} }
// 모든 모듈이 공유하는 Maven 식별자입니다.
allprojects { allprojects {
group = 'io.shinhanlife' group = 'io.shinhanlife'
version = '0.0.1-SNAPSHOT' version = '0.0.1-SNAPSHOT'
} }
// dap-gateway, dap-was-lib, dap-was-oth, dap-was-sms에 공통 적용합니다.
subprojects { subprojects {
apply plugin: 'java' apply plugin: 'java'
apply plugin: 'io.spring.dependency-management' apply plugin: 'io.spring.dependency-management'
java { java {
// 프로젝트 표준 Java 버전입니다.
sourceCompatibility = '21' sourceCompatibility = '21'
} }
repositories { repositories {
// 현재 공개 정식 라이브러리 저장소입니다.
// 폐쇄망 적용 시 사내 Nexus Proxy URL로 교체합니다.
mavenCentral() mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://repo.spring.io/snapshot' }
} }
dependencyManagement { dependencyManagement {
imports { imports {
// Spring Boot 3.5.11과 호환되는 Spring/Jackson/Tomcat 등의 버전을 관리합니다.
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
mavenBom "org.springframework.ai:spring-ai-bom:2.0.0"
// Spring AI, MCP, OpenAI 연동 라이브러리의 호환 버전을 1.1.8로 고정합니다.
mavenBom 'org.springframework.ai:spring-ai-bom:1.1.8'
} }
} }
dependencies { dependencies {
// Lombok은 컴파일 시 getter/builder 등 반복 코드를 생성하며 실행 JAR에는 포함하지 않습니다.
compileOnly 'org.projectlombok:lombok:1.18.32' compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32' annotationProcessor 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' testCompileOnly 'org.projectlombok:lombok:1.18.32'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
// Tool DTO와 MCI 요청/응답 객체 간 Converter 구현체를 컴파일 시 자동 생성합니다.
implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
// 모든 모듈의 JUnit 5 기반 테스트 공통 의존성입니다.
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
} }
tasks.withType(JavaCompile) { tasks.withType(JavaCompile) {
// 리플렉션/MCP Schema 생성 시 메서드 파라미터명을 사용할 수 있도록 보존합니다.
options.compilerArgs << '-parameters' options.compilerArgs << '-parameters'
// MapStruct 구현체를 Spring Bean으로 생성해 생성자 주입으로 사용할 수 있게 합니다.
options.compilerArgs << '-Amapstruct.defaultComponentModel=spring' options.compilerArgs << '-Amapstruct.defaultComponentModel=spring'
} }
tasks.withType(Test) { tasks.withType(Test) {
// JUnit 5 테스트 플랫폼을 사용합니다.
useJUnitPlatform() useJUnitPlatform()
} }
} }
// Tool 이름 중복 검사 프로그램이 포함된 공통 라이브러리 모듈입니다.
def toolCoreProject = project(':dap-was-lib') def toolCoreProject = project(':dap-was-lib')
// 전체 Tool Pod의 @McpTool(name) 중복을 배포 산출물 생성 전에 차단합니다.
tasks.register('validateMcpToolNames', JavaExec) { tasks.register('validateMcpToolNames', JavaExec) {
group = 'verification' group = 'verification'
description = 'Checks duplicate @McpFunction names across all Tool modules before packaging.' description = 'Checks duplicate @McpTool names across all Tool modules before packaging.'
// 검사 Runner를 실행하기 전에 dap-was-lib 클래스를 먼저 컴파일합니다.
dependsOn toolCoreProject.tasks.named('classes') dependsOn toolCoreProject.tasks.named('classes')
classpath = toolCoreProject.sourceSets.main.runtimeClasspath classpath = toolCoreProject.sourceSets.main.runtimeClasspath
mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner') mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner')
@@ -62,6 +90,7 @@ tasks.register('validateMcpToolNames', JavaExec) {
} }
subprojects { subprojects {
// 배포용 Spring Boot JAR 생성 전에 Tool 이름 중복 검증을 강제합니다.
tasks.matching { it.name == 'bootJar' }.configureEach { tasks.matching { it.name == 'bootJar' }.configureEach {
dependsOn rootProject.tasks.named('validateMcpToolNames') dependsOn rootProject.tasks.named('validateMcpToolNames')
} }

View File

@@ -1,39 +1,34 @@
plugins { plugins {
// Gateway를 독립 실행 가능한 Spring Boot JAR로 생성합니다.
id 'org.springframework.boot' id 'org.springframework.boot'
} }
dependencies { dependencies {
// Tool Registry, MCP 공통 처리, 보안/Schema 유틸리티를 공통 라이브러리에서 가져옵니다.
implementation project(':dap-was-lib') implementation project(':dap-was-lib')
// Gateway REST API와 관리 화면의 HTTP 요청을 처리합니다.
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
// Tool Registry 및 분산 캐시 연동에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring AI MCP Server // Tool/Registry 관련 DB 조회와 MyBatis Mapper 실행에 사용합니다.
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-openai:2.0.0'
// MyBatis & DB
implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
// Gateway가 /mcp Endpoint를 MCP Server로 노출하도록 지원합니다.
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
// ChatClient를 통해 OpenAI/OpenRouter 호환 모델을 호출합니다.
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
// 로컬 개발·테스트용 인메모리 DB입니다. 운영 DB에는 사용하지 않습니다.
runtimeOnly 'com.h2database:h2' runtimeOnly 'com.h2database:h2'
// SQL 로그를 확인하기 위한 JDBC 프록시입니다.
implementation 'p6spy:p6spy:3.9.1' implementation 'p6spy:p6spy:3.9.1'
// MapStruct // Gateway REST API 문서와 Swagger UI를 제공합니다.
implementation "org.mapstruct:mapstruct:1.5.5.Final"
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.5.Final"
// implementation 'com.networknt:json-schema-validator:1.4.0' // Spring AI 내장 버전과 충돌 방지를 위해 주석 처리
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
} }

View File

@@ -75,9 +75,9 @@ public class ChatController {
} }
} }
String selectedModel = request.getOrDefault("model", "gemini-flash-latest").trim(); String selectedModel = request.getOrDefault("model", "cohere/north-mini-code:free").trim();
if (selectedModel.isEmpty()) { if (selectedModel.isEmpty()) {
selectedModel = "gemini-flash-latest"; selectedModel = "cohere/north-mini-code:free";
} }
// 1. gemini-flash-latest 선택 시 OpenRouter의 Google Gemma 4 모델로 라우팅 // 1. gemini-flash-latest 선택 시 OpenRouter의 Google Gemma 4 모델로 라우팅
@@ -93,9 +93,9 @@ public class ChatController {
Flux<String> responseStream = activeChatClient.prompt() Flux<String> responseStream = activeChatClient.prompt()
.user(message) .user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools() .toolCallbacks(callbacks.toArray(new ToolCallback[0]))
.options(org.springframework.ai.openai.OpenAiChatOptions.builder() .options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(selectedModel)) .model(selectedModel).build())
.stream() .stream()
.content(); .content();

View File

@@ -113,10 +113,15 @@ public class McpRouterController {
} }
private List<ToolMetadata> fetchAllActiveTools() { private List<ToolMetadata> fetchAllActiveTools() {
List<ToolMetadata> activeTools = redisRegistryService.getAllTools() List<ToolMetadata> activeTools = new ArrayList<>();
try {
activeTools.addAll(redisRegistryService.getAllTools()
.stream() .stream()
.filter(ToolMetadata::getVisible) .filter(ToolMetadata::getVisible)
.collect(Collectors.toList()); .collect(Collectors.toList()));
} catch (org.springframework.data.redis.RedisConnectionFailureException exception) {
log.warn("Redis is unavailable. Fetching tools from configured fallback Tool Pods instead.");
}
Set<String> knownTools = activeTools.stream() Set<String> knownTools = activeTools.stream()
.map(ToolMetadata::getUid) .map(ToolMetadata::getUid)

View File

@@ -18,6 +18,8 @@ package io.shinhanlife.dap.mcg.presentation;
import io.shinhanlife.dap.lib.util.PodScaffolder; import io.shinhanlife.dap.lib.util.PodScaffolder;
import io.shinhanlife.dap.lib.util.ToolScaffolder; import io.shinhanlife.dap.lib.util.ToolScaffolder;
import io.shinhanlife.dap.lib.util.ToolSourceUpdater; import io.shinhanlife.dap.lib.util.ToolSourceUpdater;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File; import java.io.File;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -66,8 +68,13 @@ public class ScaffoldingController {
String clientSystemCode = req.get("clientSystemCode"); String clientSystemCode = req.get("clientSystemCode");
String inputSchemaResource = req.get("inputSchemaResource"); String inputSchemaResource = req.get("inputSchemaResource");
String outputSchemaResource = req.get("outputSchemaResource"); String outputSchemaResource = req.get("outputSchemaResource");
List<ToolScaffolder.FieldDefinition> inputFields = parseFields(req.get("inputFields"));
List<ToolScaffolder.FieldDefinition> outputFields = parseFields(req.get("outputFields"));
if (inputFields.isEmpty()) {
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false));
}
return ToolScaffolder.scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource); return ToolScaffolder.scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields);
} catch (Exception e) { } catch (Exception e) {
return "오류 발생: " + e.getMessage(); return "오류 발생: " + e.getMessage();
} }
@@ -107,4 +114,11 @@ public class ScaffoldingController {
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms"); return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
} }
} }
private List<ToolScaffolder.FieldDefinition> parseFields(String source) throws Exception {
if (source == null || source.isBlank()) {
return List.of();
}
return new ObjectMapper().readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
}
} }

View File

@@ -81,7 +81,7 @@ public class ToolPlanner {
} }
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증 // 2-1. [신규] 도메인 그룹핑 기반 권한 검증
if (tenantId != null && toolMetadata.getCategoryKey() != null) { if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
String normalizedTenantId = tenantId.toLowerCase(); String normalizedTenantId = tenantId.toLowerCase();
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId); List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);

View File

@@ -179,7 +179,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
log.info("Message sent to session handler"); log.info("Message sent to session handler");
if (emitter != null && !map.containsKey("id")) { if (emitter != null && !map.containsKey("id")) {
// emitter.complete(); emitter.complete();
log.info("Completed emitter for notification (disabled for keep-alive)"); log.info("Completed emitter for notification (disabled for keep-alive)");
} }
@@ -220,7 +220,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
// Custom 프로토콜: 1회 요청당 1응답 후 종료 (스트림을 닫아버림) // Custom 프로토콜: 1회 요청당 1응답 후 종료 (스트림을 닫아버림)
// 클라이언트가 한 번의 POST 후 응답을 받고 연결을 끊기 때문 // 클라이언트가 한 번의 POST 후 응답을 받고 연결을 끊기 때문
// this.emitter.complete(); this.emitter.complete();
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error sending message to SSE emitter", e); log.error("Error sending message to SSE emitter", e);

View File

@@ -55,6 +55,7 @@ public class DynamicMcpServerManager {
getOrCreateServer("email"); getOrCreateServer("email");
getOrCreateServer("oth"); getOrCreateServer("oth");
getOrCreateServer("sample"); getOrCreateServer("sample");
getOrCreateServer("smp"); // smp 카테고리도 기본적으로 항상 열어두도록 추가
} }
private McpSyncServer getOrCreateServer(String categoryKey) { private McpSyncServer getOrCreateServer(String categoryKey) {
@@ -68,7 +69,7 @@ public class DynamicMcpServerManager {
CustomWebMvcSseServerTransportProvider transport = new CustomWebMvcSseServerTransportProvider(ssePath, msgPath, objectMapper); CustomWebMvcSseServerTransportProvider transport = new CustomWebMvcSseServerTransportProvider(ssePath, msgPath, objectMapper);
McpSyncServer newServer = McpServer.sync(transport) McpSyncServer newServer = McpServer.sync(transport)
.serverInfo("DAP-Gateway-" + key, "1.0.0") .serverInfo("dap-was-" + key, "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build()) .capabilities(ServerCapabilities.builder().tools(true).build())
.build(); .build();

View File

@@ -51,13 +51,14 @@ public class RegistryMcpToolSpecificationFactory {
McpSchema.Tool tool = McpSchema.Tool.builder() McpSchema.Tool tool = McpSchema.Tool.builder()
.name(entry.getName()) .name(entry.getName())
.description(description(entry)) .description(description(entry))
.inputSchema(inputSchema(entry)) .inputSchema(toJsonSchema(inputSchema(entry)))
.annotations(McpSchema.ToolAnnotations.builder() .annotations(new McpSchema.ToolAnnotations(
.readOnlyHint(Boolean.TRUE.equals(entry.getReadOnlyHint())) entry.getDisplayName(),
.destructiveHint(Boolean.TRUE.equals(entry.getDestructiveHint())) entry.getReadOnlyHint(),
.idempotentHint(Boolean.TRUE.equals(entry.getIdempotentHint())) entry.getDestructiveHint(),
.openWorldHint(Boolean.TRUE.equals(entry.getOpenWorldHint())) entry.getIdempotentHint(),
.build()) entry.getOpenWorldHint(),
null))
.build(); .build();
return McpServerFeatures.SyncToolSpecification.builder() return McpServerFeatures.SyncToolSpecification.builder()
@@ -147,6 +148,20 @@ public class RegistryMcpToolSpecificationFactory {
return schema; return schema;
} }
@SuppressWarnings("unchecked")
private McpSchema.JsonSchema toJsonSchema(Map<String, Object> schema) {
return new McpSchema.JsonSchema(
String.valueOf(schema.getOrDefault("type", "object")),
schema.get("properties") instanceof Map<?, ?> properties
? (Map<String, Object>) properties : Map.of(),
schema.get("required") instanceof List<?> required
? (List<String>) required : List.of(),
schema.get("additionalProperties") instanceof Boolean additionalProperties
? additionalProperties : Boolean.TRUE,
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
schema.get("definitions") instanceof Map<?, ?> definitions
? (Map<String, Object>) definitions : Map.of());
}
private String description(ToolMetadata entry) { private String description(ToolMetadata entry) {
return entry.getDescription() == null || entry.getDescription().isBlank() return entry.getDescription() == null || entry.getDescription().isBlank()
? entry.getName() + " Tool" ? entry.getName() + " Tool"

View File

@@ -13,8 +13,9 @@ spring:
api-key: ${OPENROUTER_API_KEY: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} api-key: ${OPENROUTER_API_KEY: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}
base-url: https://openrouter.ai/api/v1 base-url: https://openrouter.ai/api/v1
chat: chat:
completions-path: /chat/completions
options: options:
model: google/gemma-4-31b-it:free model: cohere/north-mini-code:free
temperature: 0.3 temperature: 0.3
server: server:
@@ -43,6 +44,8 @@ mcp:
tenant-domains: tenant-domains:
TESTER-DEV: ALL TESTER-DEV: ALL
system: ALL system: ALL
AUTO-TESTER: ALL
auto-tester: ALL
mybatis: mybatis:
mapper-locations: classpath:mapper/**/*.xml mapper-locations: classpath:mapper/**/*.xml

View File

@@ -495,6 +495,29 @@
</div> </div>
</div> </div>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Input Fields (JSON)</label>
<div class="d-flex gap-1">
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="loadFieldExample('inputFields')">예제 넣기</button>
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="copyFieldJson('inputFields')">복사</button>
</div>
</div>
<textarea id="inputFields" class="form-control" name="inputFields" rows="4" placeholder='[{"name":"employeeId","type":"String","description":"Employee ID","example":"EMP10001","required":true}]'></textarea>
</div>
<div class="col-md-6 mt-3 mt-md-0">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Output Fields (JSON)</label>
<div class="d-flex gap-1">
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="loadFieldExample('outputFields')">예제 넣기</button>
<button type="button" class="btn-secondary-action" style="padding: 0.2rem 0.45rem; font-size: 0.72rem;" onclick="copyFieldJson('outputFields')">복사</button>
</div>
</div>
<textarea id="outputFields" class="form-control" name="outputFields" rows="4" placeholder='[{"name":"employeeName","type":"String","description":"Employee name","example":"Hong Gildong","required":true}]'></textarea>
</div>
</div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Target Module</label> <label class="form-label">Target Module</label>
@@ -685,6 +708,33 @@
handleFormSubmit('podForm', '/api/v1/scaffold/pod'); handleFormSubmit('podForm', '/api/v1/scaffold/pod');
handleFormSubmit('toolForm', '/api/v1/scaffold/tool'); handleFormSubmit('toolForm', '/api/v1/scaffold/tool');
const fieldExamples = {
inputFields: [
{ name: 'employeeId', type: 'String', description: 'Employee identifier', example: 'EMP10001', required: true },
{ name: 'page', type: 'Integer', description: 'Page number', example: '1', required: false }
],
outputFields: [
{ name: 'employeeName', type: 'String', description: 'Employee name', example: 'Hong Gildong', required: true }
]
};
function loadFieldExample(fieldId) {
document.getElementById(fieldId).value = JSON.stringify(fieldExamples[fieldId], null, 2);
}
async function copyFieldJson(fieldId) {
const textarea = document.getElementById(fieldId);
const value = textarea.value.trim() || JSON.stringify(fieldExamples[fieldId], null, 2);
try {
await navigator.clipboard.writeText(value);
} catch (error) {
textarea.value = value;
textarea.select();
document.execCommand('copy');
textarea.setSelectionRange(0, 0);
}
}
function loadToolList() { function loadToolList() {
const tbody = document.getElementById('toolListBody'); const tbody = document.getElementById('toolListBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-5 text-muted">Loading data...</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center py-5 text-muted">Loading data...</td></tr>';

View File

@@ -85,11 +85,11 @@
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer"> <select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer">
<option value="inclusionai/ling-3.0-flash:free" selected>Ling 3.0 Flash ⭐ (기본 - OpenRouter)</option> <option value="inclusionai/ling-3.0-flash:free">Ling 3.0 Flash (무료 - 현재 제공 상태에 따라 제한될 수 있음)</option>
<option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option> <option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
<option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option> <option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron Nano 30B (무료 - OpenRouter)</option> <option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron Nano 30B (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free">Cohere North Mini (무료 - OpenRouter)</option> <option value="cohere/north-mini-code:free" selected>Cohere North Mini (기본 · 무료 - OpenRouter)</option>
<option value="gemini-flash-latest">Gemini 1.5 Flash (직접 API - Google)</option> <option value="gemini-flash-latest">Gemini 1.5 Flash (직접 API - Google)</option>
</select> </select>

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.mcg.presentation;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.http.ResponseEntity;
class McpRouterControllerTest {
@Test
void returnsEmptyToolListWhenRedisIsUnavailableAndNoFallbackIsConfigured() {
RedisRegistryService registryService = org.mockito.Mockito.mock(RedisRegistryService.class);
when(registryService.getAllTools()).thenThrow(new RedisConnectionFailureException("Redis unavailable"));
McpRouterController controller = new McpRouterController(
registryService,
org.mockito.Mockito.mock(ExecuteService.class),
org.mockito.Mockito.mock(SecurityProperties.class),
new ObjectMapper(),
new GatewayFallbackProperties());
ResponseEntity<JsonRpcResponse> response = assertDoesNotThrow(() -> controller.listTools(null));
Map<?, ?> result = (Map<?, ?>) response.getBody().getResult();
assertEquals(List.of(), result.get("tools"));
}
}

View File

@@ -1,40 +1,55 @@
plugins { plugins {
// Gateway와 모든 Tool Pod가 의존하는 공통 라이브러리 모듈입니다.
id 'java-library' id 'java-library'
} }
dependencies { dependencies {
// 공통 REST Controller, HTTP Client, 예외 처리 기반입니다.
api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-web'
// Tool Request DTO의 Bean Validation을 지원합니다.
api 'org.springframework.boot:spring-boot-starter-validation' api 'org.springframework.boot:spring-boot-starter-validation'
// Tool Manifest/Registry 캐시 및 Redis 기반 공통 기능을 제공합니다.
api 'org.springframework.boot:spring-boot-starter-data-redis' api 'org.springframework.boot:spring-boot-starter-data-redis'
// Tool SLA, 로깅, 공통 Aspect를 적용합니다.
api 'org.springframework.boot:spring-boot-starter-aop'
// MCI/DB 연동에 필요한 JDBC 공통 기능입니다.
api 'org.springframework.boot:spring-boot-starter-jdbc'
// Tool 호출의 Circuit Breaker, Rate Limit, Retry 등 복원력 기능입니다.
api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
api 'io.github.resilience4j:resilience4j-core:2.2.0' api 'io.github.resilience4j:resilience4j-core:2.2.0'
api 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' api 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
api 'io.github.resilience4j:resilience4j-ratelimiter' api 'io.github.resilience4j:resilience4j-ratelimiter'
api 'io.github.resilience4j:resilience4j-retry:2.2.0' api 'io.github.resilience4j:resilience4j-retry:2.2.0'
api 'org.springframework.boot:spring-boot-starter-aop:3.3.0'
api 'org.springframework.boot:spring-boot-starter-jdbc' // MCI/업무 데이터 접근을 위한 MyBatis 지원입니다.
api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
// 현재 로컬 테스트 DB 및 SQL 로그 처리에 사용합니다.
api 'com.h2database:h2' api 'com.h2database:h2'
api 'p6spy:p6spy:3.9.1' api 'p6spy:p6spy:3.9.1'
// Spring Data Redis가 사용하는 Redis Client를 공통 모듈에서 직접 참조합니다.
api 'io.lettuce:lettuce-core:6.6.0.RELEASE' api 'io.lettuce:lettuce-core:6.6.0.RELEASE'
api "org.mapstruct:mapstruct:1.5.5.Final" // MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다.
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1' api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1' api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:3.0.0' api 'com.networknt:json-schema-validator:3.0.0'
// Tool Pod의 /mcp Streamable HTTP Server와 MCP SDK를 제공합니다.
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc' api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
// Spring AI 1.1.x에서 @McpTool, @McpToolParam, metaProvider를 제공합니다.
api 'org.springaicommunity:mcp-annotations:0.9.0'
// 이벤트 기반 확장이 필요한 Tool의 공통 Kafka 연동 기능입니다.
api 'org.springframework.kafka:spring-kafka:3.2.0' api 'org.springframework.kafka:spring-kafka:3.2.0'
// Tool Pod REST API 문서 및 Swagger UI를 제공합니다.
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
} }
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok:1.18.32'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -0,0 +1,14 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a response DTO whose generated JSON Schema must be exposed and validated for a Tool response.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface McpOutputSchema {
}

View File

@@ -23,7 +23,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature; import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch; import org.springframework.util.StopWatch;
@@ -36,7 +36,7 @@ public class ToolSlaMonitoringAspect {
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
// @McpTool 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다. // @McpTool 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)") @Around("@annotation(org.springaicommunity.mcp.annotation.McpTool)")
public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable { public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature(); MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod(); Method method = signature.getMethod();

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.lib.config;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* Registers the temporary Glow HTTP compatibility component from the DAP library scan scope.
* The bean is only created when an official GlowHttpComponent has not already been supplied.
*/
@Configuration(proxyBeanMethods = false)
public class AxhubHttpConfiguration {
@Bean
@ConditionalOnMissingBean(GlowHttpComponent.class)
public GlowHttpComponent glowHttpComponent(RestClient.Builder restClientBuilder) {
return new GlowHttpComponent(restClientBuilder);
}
}

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -15,4 +16,9 @@ public class ToolSchemaConfiguration {
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) { public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
return new ToolSchemaResolver(objectMapper); return new ToolSchemaResolver(objectMapper);
} }
@Bean
public ToolArgumentSchemaValidator toolArgumentSchemaValidator(ObjectMapper objectMapper) {
return new ToolArgumentSchemaValidator(objectMapper);
}
} }

View File

@@ -0,0 +1,124 @@
package io.shinhanlife.dap.lib.integration.http.component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import io.shinhanlife.glow.communication.module.http.dto.HttpBody;
import io.shinhanlife.glow.communication.module.http.dto.HttpHeader;
import io.shinhanlife.glow.communication.module.http.dto.HttpTransfer;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/**
* Tool Pod outbound HTTP component modelled after the ShinhanLife HTTP component.
* It resolves an API domain from configuration and delegates the assembled HttpTransfer to Glow.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AxhubHttpComponent {
private static final String ANONYMOUS_REQUEST = "AXHUB-TOOL";
private final GlowHttpComponent http;
private final ObjectMapper json;
private final GlowCommunicationProperties communicationProperties;
private final AxhubHttpProperties properties;
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
return call(domain, uri, inputDto, responseBodyClass, 0);
}
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass, int timeout) {
if (uri == null || uri.isBlank()) {
throw new IllegalArgumentException("URI is required.");
}
AxhubHttpProperties.ApiDefinition api = resolveApi(domain);
HttpHeader header = createHeader(api, timeout);
String requestUri = joinPath(api.path(), uri);
HttpTransfer<T> request = HttpTransfer.<T>http()
.header(header)
.domain(api.domain())
.uri(requestUri)
.method(api.method())
.contentType(MediaType.APPLICATION_JSON)
.responseEntity(responseBodyClass)
.body(inputDto)
.build();
log.info("[AxhubHttpComponent] Glow HTTP call. domain={}, method={}, uri={}",
domain.getCode(), api.method(), requestUri);
ResponseEntity<HttpBody> response = http.sync(request);
return convertResponse(response.getBody(), responseBodyClass);
}
/** Convenience method for APIs configured as internal business Pods. */
public <T, R> R callBizPod(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
AxhubHttpProperties.ApiDefinition api = resolveApi(domain);
if (!api.bizPod()) {
throw new IllegalArgumentException("Configured API is not a business Pod: " + domain.getCode());
}
return call(domain, uri, inputDto, responseBodyClass);
}
private AxhubHttpProperties.ApiDefinition resolveApi(AxhubHttpDomain domain) {
return properties.getApiList().stream()
.filter(api -> domain.getCode().equals(api.name()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No HTTP API configuration for domain: " + domain.getCode()));
}
private HttpHeader createHeader(AxhubHttpProperties.ApiDefinition api, int timeout) {
HttpHeader header = new HttpHeader();
if (api.bizPod()) {
header.set("X-POD-TO-POD", "true");
}
McpRequestHeaders inbound = McpRequestHeaderContext.current();
if (inbound == null) {
header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST);
} else {
putIfPresent(header, "trace-id", inbound.traceId());
putIfPresent(header, "request-id", inbound.requestId());
putIfPresent(header, "X-USER-ID", inbound.encryptedEmployeeId());
}
header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout);
return header;
}
private int defaultReadTimeout() {
return communicationProperties == null || communicationProperties.getHttp() == null
? 0 : communicationProperties.getHttp().getReadTimeout();
}
private <R> R convertResponse(HttpBody responseBody, Class<R> responseBodyClass) {
String content = responseBody == null ? null : responseBody.content();
if (responseBodyClass == String.class) {
return responseBodyClass.cast(content);
}
try {
return json.readValue(content, responseBodyClass);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to convert HTTP response to " + responseBodyClass.getSimpleName(), e);
}
}
private void putIfPresent(HttpHeader header, String name, String value) {
if (value != null && !value.isBlank()) {
header.set(name, value);
}
}
private String joinPath(String basePath, String uri) {
String left = basePath == null ? "" : basePath.replaceAll("/+$", "");
String right = uri.startsWith("/") ? uri : "/" + uri;
return left + right;
}
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.dap.lib.integration.http.component;
/** Registered outbound HTTP API domains. Add a domain only after its endpoint is configured. */
public enum AxhubHttpDomain {
SAMPLE("sample");
private final String code;
AxhubHttpDomain(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.lib.integration.http.component;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
/** Domain-to-endpoint configuration for outbound Tool HTTP calls. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "glow.communication.http")
public class AxhubHttpProperties {
private List<ApiDefinition> apiList = new ArrayList<>();
public record ApiDefinition(String name, String domain, String path, HttpMethod method, boolean bizPod) {
}
}

View File

@@ -0,0 +1,101 @@
package io.shinhanlife.dap.lib.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** Executes a cached Tool independently from its HTTP or MCP transport. */
@Slf4j
@Service
@RequiredArgsConstructor
public class McpToolExecutionService {
private final McpToolMethodRegistry toolMethodRegistry;
private final ObjectMapper objectMapper;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
private final ToolSchemaResolver toolSchemaResolver;
public ToolExecutionResult execute(String functionName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) {
String headerRequestId = requestHeaders == null ? null : requestHeaders.headerRequestId();
String traceId = requestHeaders == null ? null : requestHeaders.traceId();
String requestId = requestHeaders == null ? null : requestHeaders.requestId();
log.info("[Tool] IN - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName);
if (resolvedTool == null) {
return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId);
}
ToolExecutionResult validationFailure = validateInput(resolvedTool, arguments, headerRequestId);
if (validationFailure != null) {
return validationFailure;
}
try {
Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments));
ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, headerRequestId);
if (outputFailure != null) {
return outputFailure;
}
Map<String, String> headers = new HashMap<>();
if (traceId != null) headers.put("trace-id", traceId);
if (requestId != null) headers.put("request-id", requestId);
log.info("[Tool] OUT - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
return new ToolExecutionResult(200, methodResult, headers);
} catch (Exception error) {
log.error("[Tool] Tool execution failed. tool={}", functionName, error);
return error(502, "TOOL_ERROR", "Tool execution failed", headerRequestId);
}
}
private ToolExecutionResult validateInput(McpToolMethodRegistry.RegisteredTool tool,
Map<String, Object> arguments, String requestId) {
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
try {
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
List<Error> errors = toolArgumentSchemaValidator.validate(schema, arguments);
return errors.isEmpty() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId);
} catch (Exception error) {
log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
return null;
}
}
private Object convertArgument(Method method, Map<String, Object> arguments) {
if (method.getParameterCount() == 0 || arguments == null || Map.class.isAssignableFrom(method.getParameterTypes()[0])) return arguments;
return objectMapper.convertValue(arguments, method.getParameterTypes()[0]);
}
private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) throws Exception {
return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument);
}
private ToolExecutionResult validateOutput(McpToolMethodRegistry.RegisteredTool tool,
Object methodResult, String requestId) {
try {
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint());
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) {
return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId);
}
} catch (Exception error) {
log.error("[Tool] Output schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
}
return null;
}
private ToolExecutionResult error(int statusCode, String code, String message, String requestId) {
Map<String, Object> body = new HashMap<>();
body.put("code", code);
body.put("message", message);
body.put("details", Map.of("status", Integer.toString(statusCode)));
if (requestId != null) body.put("request_id", requestId);
return new ToolExecutionResult(statusCode, body, Map.of());
}
}

View File

@@ -0,0 +1,87 @@
package io.shinhanlife.dap.lib.mcp;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.aop.support.AopUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Caches executable {@link McpTool} methods once when a Tool Pod starts.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class McpToolMethodRegistry {
private final ApplicationContext applicationContext;
private final McpProperties mcpProperties;
private volatile Map<String, RegisteredTool> tools = Map.of();
@EventListener(ApplicationReadyEvent.class)
public void initialize() {
Map<String, RegisteredTool> discovered = new LinkedHashMap<>();
for (Object bean : applicationContext.getBeansOfType(Object.class).values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
for (Method declaredMethod : targetClass.getDeclaredMethods()) {
McpTool annotation = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class);
if (annotation == null) {
continue;
}
RegisteredTool tool = new RegisteredTool(
bean,
findInvocableMethod(bean, declaredMethod),
annotation,
AnnotationUtils.findAnnotation(declaredMethod, ToolHint.class));
register(discovered, annotation.name(), tool);
registerNamespaceAlias(discovered, annotation.name(), tool);
}
}
tools = Map.copyOf(discovered);
log.info("[Tool Registry] {} executable tool names cached", tools.size());
}
public RegisteredTool find(String toolName) {
return tools.get(toolName);
}
private void registerNamespaceAlias(Map<String, RegisteredTool> discovered, String toolName,
RegisteredTool tool) {
String namespace = mcpProperties.getNamespace();
if (StringUtils.hasText(namespace)) {
register(discovered, namespace + "_" + toolName, tool);
}
}
private void register(Map<String, RegisteredTool> discovered, String toolName, RegisteredTool tool) {
RegisteredTool existing = discovered.putIfAbsent(toolName, tool);
if (existing != null && existing != tool) {
throw new IllegalStateException("Duplicate @McpTool name: " + toolName);
}
}
private Method findInvocableMethod(Object bean, Method declaredMethod) {
try {
return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes());
} catch (NoSuchMethodException ignored) {
return declaredMethod;
}
}
public record RegisteredTool(Object bean, Method method, McpTool annotation, ToolHint hint) {
}
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.lib.mcp;
import java.util.Map;
/** Protocol-neutral result of invoking one Tool Pod business tool. */
public record ToolExecutionResult(int statusCode, Object body, Map<String, String> headers) {
public ToolExecutionResult {
headers = headers == null ? Map.of() : Map.copyOf(headers);
}
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;
}
}

View File

@@ -5,33 +5,29 @@ import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */ /** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component @Component
@ConditionalOnBean(BusinessToolController.class) @ConditionalOnBean(McpToolExecutionService.class)
public class ToolPodMcpToolSynchronizer { public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer; private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender; private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController; private final McpToolExecutionService toolExecutionService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender, public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) { McpToolExecutionService toolExecutionService, ObjectMapper objectMapper) {
this.mcpServer = mcpServer; this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender; this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController; this.toolExecutionService = toolExecutionService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -46,12 +42,14 @@ public class ToolPodMcpToolSynchronizer {
McpSchema.Tool mcpTool = McpSchema.Tool.builder() McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName()) .name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription()) .description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema()) .inputSchema(toJsonSchema(tool.getParametersSchema()))
.annotations(McpSchema.ToolAnnotations.builder() .annotations(new McpSchema.ToolAnnotations(
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint())) tool.getDisplayName(),
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint())) tool.getReadOnlyHint(),
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint())) tool.getDestructiveHint(),
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build()) tool.getIdempotentHint(),
tool.getOpenWorldHint(),
null))
.build(); .build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool) return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build(); .callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
@@ -59,15 +57,9 @@ public class ToolPodMcpToolSynchronizer {
private McpSchema.CallToolResult invoke(String toolName, McpRequestHeaders requestHeaders, private McpSchema.CallToolResult invoke(String toolName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) { Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool( ToolExecutionResult result = toolExecutionService.execute(toolName, requestHeaders, arguments);
toolName, boolean failed = !result.isSuccess();
requestHeaders == null ? null : requestHeaders.headerRequestId(), Object body = result.body();
requestHeaders == null ? null : requestHeaders.traceId(),
requestHeaders == null ? null : requestHeaders.requestId(),
requestHeaders == null ? null : requestHeaders.encryptedEmployeeId(),
arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
try { try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body)) return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build(); .structuredContent(body).isError(failed).build();
@@ -83,4 +75,19 @@ public class ToolPodMcpToolSynchronizer {
schema.put("additionalProperties", false); schema.put("additionalProperties", false);
return schema; return schema;
} }
@SuppressWarnings("unchecked")
private McpSchema.JsonSchema toJsonSchema(Map<String, Object> source) {
Map<String, Object> schema = source == null ? emptySchema() : source;
return new McpSchema.JsonSchema(
String.valueOf(schema.getOrDefault("type", "object")),
schema.get("properties") instanceof Map<?, ?> properties
? (Map<String, Object>) properties : Map.of(),
schema.get("required") instanceof List<?> required
? (List<String>) required : List.of(),
schema.get("additionalProperties") instanceof Boolean additionalProperties
? additionalProperties : Boolean.TRUE,
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
schema.get("definitions") instanceof Map<?, ?> definitions
? (Map<String, Object>) definitions : Map.of());
}
} }

View File

@@ -16,7 +16,7 @@ package io.shinhanlife.dap.lib.mcp;
* </pre> * </pre>
*/ */
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
@@ -44,14 +44,13 @@ import org.springframework.util.ClassUtils;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
@Slf4j @Slf4j
@Component @Component
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@RequiredArgsConstructor @RequiredArgsConstructor
@ConditionalOnBean(BusinessToolController.class) @ConditionalOnBean(McpToolExecutionService.class)
public class ToolRegistryHeartbeatSender { public class ToolRegistryHeartbeatSender {
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
@@ -92,10 +91,13 @@ public class ToolRegistryHeartbeatSender {
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + rawSubToolName ? mcpProperties.getNamespace() + "_" + rawSubToolName
: rawSubToolName; : rawSubToolName;
// @ToolHint(register = false)인 Tool은 메타데이터 조회에는 남기되,
boolean isRegister = hintAnnotation != null && hintAnnotation.register(); // Gateway 등록 및 heartbeat 전송 대상에서는 제외합니다.
// ToolHint가 없는 기존 Tool은 이전 동작과 동일하게 등록합니다.
boolean isRegister = hintAnnotation == null || hintAnnotation.register();
if (!isRegister) { if (!isRegister) {
log.info(" [HeartbeatSender] '{}' 툴은 어노테이션 설정에 의해 외부 등록(Redis) 대상에서 제외되었습니다. (최종 이름: {})", baseName, subToolName); log.info(" [HeartbeatSender] '{}' Tool is excluded from Gateway registration because register=false. (tool name: {})",
baseName, subToolName);
} }
ToolMetadata meta = new ToolMetadata(); ToolMetadata meta = new ToolMetadata();

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType; import java.lang.reflect.ParameterizedType;
@@ -76,7 +76,8 @@ public class JsonSchemaGenerator {
if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) { if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) {
fieldSchema.put("description", schemaAnnotation.description()); fieldSchema.put("description", schemaAnnotation.description());
} }
if (schemaAnnotation.required() && !requiredList.contains(field.getName())) { if ((schemaAnnotation.required() || schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED)
&& !requiredList.contains(field.getName())) {
requiredList.add(field.getName()); requiredList.add(field.getName());
} }
if (!schemaAnnotation.pattern().isEmpty()) { if (!schemaAnnotation.pattern().isEmpty()) {

View File

@@ -46,7 +46,11 @@ public class PodScaffolder {
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author, String createDate) throws IOException { public static String scaffoldPod(String moduleName, String portStr, String shortName, String author, String createDate) throws IOException {
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR"); String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get("."); Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
return scaffoldPod(rootDir, moduleName, portStr, shortName, author, createDate);
}
static String scaffoldPod(Path rootDir, String moduleName, String portStr, String shortName,
String author, String createDate) throws IOException {
Path modulePath = rootDir.resolve(Paths.get(moduleName)); Path modulePath = rootDir.resolve(Paths.get(moduleName));
if (Files.exists(modulePath)) { if (Files.exists(modulePath)) {
return "[오류] 이미 존재하는 모듈입니다: " + moduleName; return "[오류] 이미 존재하는 모듈입니다: " + moduleName;
@@ -59,25 +63,27 @@ public class PodScaffolder {
log.append("[2/6] build.gradle 생성 중...\n"); log.append("[2/6] build.gradle 생성 중...\n");
String buildGradle = """ String buildGradle = """
plugins { plugins {
// Spring Boot 3.5.11 version is managed by the root build.gradle.
id 'org.springframework.boot' id 'org.springframework.boot'
} }
dependencies { dependencies {
// Shared MCP, Glow integration, validation, logging, Lombok and MapStruct configuration.
implementation project(':dap-was-lib') implementation project(':dap-was-lib')
} }
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}
"""; """;
Files.writeString(modulePath.resolve("build.gradle"), buildGradle); Files.writeString(modulePath.resolve("build.gradle"), buildGradle);
log.append("[3/6] Dockerfile 생성 중...\n"); log.append("[3/6] Dockerfile 생성 중...\n");
String dockerfile = """ String dockerfile = """
FROM eclipse-temurin:21-jdk-alpine FROM eclipse-temurin:21-jre-alpine
WORKDIR /app WORKDIR /app
COPY build/libs/%s-0.0.1-SNAPSHOT.jar app.jar RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
COPY %s/build/libs/*-SNAPSHOT.jar app.jar
EXPOSE %s
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]
""".formatted(moduleName); """.formatted(moduleName, portStr);
Files.writeString(modulePath.resolve("Dockerfile"), dockerfile); Files.writeString(modulePath.resolve("Dockerfile"), dockerfile);
log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n"); log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n");
@@ -132,15 +138,24 @@ public class PodScaffolder {
org.apache.kafka: ERROR org.apache.kafka: ERROR
mcp: mcp:
namespace: "" namespace: ""
manifest:
bundle-id: %s
name-prefix: ""
security: security:
tenant-domains: tenant-domains:
TESTER-DEV: ALL TESTER-DEV: ALL
""".formatted(portStr, moduleName); """.formatted(portStr, moduleName, moduleName.replace("dap-", ""));
Files.writeString(resPath.resolve("application.yml"), applicationYml); Files.writeString(resPath.resolve("application.yml"), applicationYml);
String applicationLocalYml = """ String applicationLocalYml = """
# Local 환경 전용 설정 (H2 메모리 DB 등) # Local 환경 전용 설정 (H2 메모리 DB 등)
spring: spring:
config:
activate:
on-profile: local
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
datasource: datasource:
url: jdbc:p6spy:h2:mem:testdb;DB_CLOSE_DELAY=-1; url: jdbc:p6spy:h2:mem:testdb;DB_CLOSE_DELAY=-1;
driverClassName: com.p6spy.engine.spy.P6SpyDriver driverClassName: com.p6spy.engine.spy.P6SpyDriver
@@ -186,6 +201,14 @@ public class PodScaffolder {
server: server:
port: ${PORT:%s} port: ${PORT:%s}
spring:
config:
activate:
on-profile: dev
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-dev.yml
axhub: axhub:
gateway: gateway:
url: https://axhubmcp.devjun.net url: https://axhubmcp.devjun.net
@@ -223,6 +246,46 @@ public class PodScaffolder {
""".formatted(portStr, portStr); """.formatted(portStr, portStr);
Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml); Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml);
String applicationTestYml = """
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: test
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-test.yml
axhub:
gateway:
url: ${AXHUB_GATEWAY_URL}
tool:
url: ${AXHUB_TOOL_URL}
""".formatted(portStr);
Files.writeString(resPath.resolve("application-test.yml"), applicationTestYml);
String applicationProdYml = """
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: prod
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-prod.yml
axhub:
gateway:
url: ${AXHUB_GATEWAY_URL}
tool:
url: ${AXHUB_TOOL_URL}
""".formatted(portStr);
Files.writeString(resPath.resolve("application-prod.yml"), applicationProdYml);
String logbackXml = """ String logbackXml = """
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>

View File

@@ -1,14 +1,16 @@
package io.shinhanlife.dap.lib.util; package io.shinhanlife.dap.lib.util;
import org.springframework.ai.mcp.annotation.McpToolParam;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
import java.util.Scanner; import java.util.Scanner;
/** /**
@@ -42,6 +44,9 @@ public class ToolScaffolder {
private static final String BASE_PACKAGE = "io.shinhanlife.dap.mcc"; private static final String BASE_PACKAGE = "io.shinhanlife.dap.mcc";
private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/mcc"; private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/mcc";
public record FieldDefinition(String name, String type, String description, String example, boolean required) {
}
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in); Scanner scanner = new Scanner(System.in);
@@ -74,10 +79,11 @@ public class ToolScaffolder {
String useSchemaResourceStr = getOrAsk(args, 8, scanner, "9. input/output JSON Schema 파일 자동 생성 여부 (y/N): "); String useSchemaResourceStr = getOrAsk(args, 8, scanner, "9. input/output JSON Schema 파일 자동 생성 여부 (y/N): ");
boolean useSchemaResource = "y".equalsIgnoreCase(useSchemaResourceStr.trim()); boolean useSchemaResource = "y".equalsIgnoreCase(useSchemaResourceStr.trim());
String inputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-input-schema.json" : null; String schemaResourceDirectory = "classpath:tool-schemas/" + group.toLowerCase() + "/";
String outputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-output-schema.json" : null; String inputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-input-schema.json" : null;
String outputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-output-schema.json" : null;
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true, null, inputSchemaResource, outputSchemaResource); String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, false, null, inputSchemaResource, outputSchemaResource);
System.out.println(result); System.out.println(result);
} }
@@ -94,6 +100,12 @@ public class ToolScaffolder {
} }
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource) throws IOException { public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource) throws IOException {
return scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate,
register, clientSystemCode, inputSchemaResource, outputSchemaResource,
List.of(new FieldDefinition("query", "String", "Search query", "example", false)), List.of());
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields) throws IOException {
baseName = toPascalCase(baseName); baseName = toPascalCase(baseName);
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR"); String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get("."); Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
@@ -110,22 +122,20 @@ public class ToolScaffolder {
String schemaBaseName = toKebabCase(baseName); String schemaBaseName = toKebabCase(baseName);
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json"; String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json"; String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mcp/schema")); Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase()));
String inputSchemaClasspath = "classpath:mcp/schema/" + inputSchemaFileName; String inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:mcp/schema/" + outputSchemaFileName; String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName;
String bizPackage = BASE_PACKAGE + ".biz." + group.toLowerCase(); String bizPackage = BASE_PACKAGE + ".biz." + group.toLowerCase();
boolean isMci = "MCI".equalsIgnoreCase(routingType); boolean isMci = "MCI".equalsIgnoreCase(routingType);
String mciGroupPath = "infra/itrf/mci/" + group.toLowerCase(); String mciGroupPath = "infra/itrf/mci/" + group.toLowerCase();
String clientPkgSuffix = "";
String clientPrefixCap = ""; String clientPrefixCap = "";
Path mciClientDir = null; Path mciClientDir = null;
if (isMci && clientSystemCode != null && clientSystemCode.length() == 4) { if (isMci && clientSystemCode != null && clientSystemCode.length() == 4) {
String clientPrefix = clientSystemCode.toLowerCase(); String clientPrefix = clientSystemCode.toLowerCase();
clientPkgSuffix = clientPrefix.substring(0, 3) + "." + clientPrefix.substring(3, 4);
clientPrefixCap = toPascalCase(clientSystemCode); clientPrefixCap = toPascalCase(clientSystemCode);
mciGroupPath = "infra/itrf/mci/" + clientPrefix.substring(0, 3) + "/" + clientPrefix.substring(3, 4); mciGroupPath = "infra/itrf/mci/" + clientPrefix;
mciClientDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, mciGroupPath)); mciClientDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, mciGroupPath));
} }
@@ -178,6 +188,14 @@ public class ToolScaffolder {
private String message; private String message;
} }
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
reqContent = reqContent
.replace("import com.fasterxml.jackson.annotation.JsonInclude;",
"import com.fasterxml.jackson.annotation.JsonInclude;\nimport io.swagger.v3.oas.annotations.media.Schema;")
.replaceAll("(?m)^\\s*@McpToolParam\\([^\\r\\n]*\\)\\R", "")
.replaceAll("(?m)^\\s*-\\(\\?:[^\\r\\n]*\\R", "")
.replace("private String phoneNumber;", "@Schema(example = \"01012345678\")\n private String phoneNumber;")
.replace("private String message;", "@Schema(example = \"테스트 메시지입니다.\")\n private String message;");
reqContent = dtoContent(bizPackage + ".dto", baseName + "Request", inputFields, author, createDate, true);
Files.writeString(dtoDir.resolve(baseName + "Request.java"), reqContent); Files.writeString(dtoDir.resolve(baseName + "Request.java"), reqContent);
// Generate Response DTO // Generate Response DTO
@@ -211,23 +229,24 @@ public class ToolScaffolder {
// TODO: Add response fields here. Do not include PII in the Tool response. // TODO: Add response fields here. Do not include PII in the Tool response.
} }
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
resContent = dtoContent(bizPackage + ".dto", baseName + "Response", outputFields, author, createDate, false);
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent); Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
String toolName = toToolName(moduleName, group, baseName); String toolName = toToolName(moduleName, group, baseName);
String toolHintLine; String toolHintLine;
if (useSchemaResource) { if (useSchemaResource) {
toolHintLine = " @ToolHint(register = %s,\n" + toolHintLine = (" @ToolHint(register = %s, categoryKey = \"%s\", mappingId = \"%s\",\n" +
" inputSchemaResource = \"%s\",\n" + " inputSchemaResource = \"%s\",\n" +
" outputSchemaResource = \"%s\")".formatted(register, inputSchemaClasspath, outputSchemaClasspath); " outputSchemaResource = \"%s\")").formatted(register, group.toLowerCase(Locale.ROOT), interfaceId, inputSchemaClasspath, outputSchemaClasspath);
} else { } else {
toolHintLine = " @ToolHint(register = %s)".formatted(register); toolHintLine = " @ToolHint(register = %s, categoryKey = \"%s\", mappingId = \"%s\")".formatted(register, group.toLowerCase(Locale.ROOT), interfaceId);
} }
String serviceInterfaceContent = """ String serviceInterfaceContent = """
package %s.usecase; package %s.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import %s.dto.%sRequest; import %s.dto.%sRequest;
import %s.dto.%sResponse; import %s.dto.%sResponse;
@@ -279,7 +298,6 @@ public class ToolScaffolder {
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import %s.converter.%sConverter; import %s.converter.%sConverter;
import %s.%s.io.%s_I; import %s.%s.io.%s_I;
%s %s
@@ -307,7 +325,7 @@ public class ToolScaffolder {
private final %sConverter converter; private final %sConverter converter;
@Override @Override
public Object execute(%sRequest req) { public %sResponse execute(%sRequest req) {
log.info("[MCI Tool] {} 요청 수신.", "%s"); log.info("[MCI Tool] {} 요청 수신.", "%s");
try { try {
// MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO) // MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO)
@@ -319,10 +337,18 @@ public class ToolScaffolder {
mciReq, mciReq,
Object.class Object.class
); );
return resTransfer.getBody() != null ? resTransfer.getBody() : Map.of("status", "SUCCESS"); %sResponse response = new %sResponse();
response.setResultCode("SUCCESS");
response.setResultMessage(resTransfer.getBody() != null
? "MCI call completed."
: "MCI call completed without a response body.");
return response;
} catch (Exception e) { } catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e); log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
return Map.of("status", "ERROR", "message", e.getMessage() != null ? e.getMessage() : "Unknown error"); %sResponse response = new %sResponse();
response.setResultCode("ERROR");
response.setResultMessage(e.getMessage() != null ? e.getMessage() : "Unknown error");
return response;
} }
} }
} }
@@ -344,12 +370,27 @@ public class ToolScaffolder {
(clientPrefixCap.isEmpty() ? "private final AxhubMciComponent mci;" : "private final Mci" + clientPrefixCap + "Client mci;"), (clientPrefixCap.isEmpty() ? "private final AxhubMciComponent mci;" : "private final Mci" + clientPrefixCap + "Client mci;"),
baseName, baseName,
baseName, baseName,
baseName,
toolName, toolName,
interfaceId, interfaceId,
interfaceId interfaceId,
baseName,
baseName,
baseName,
baseName
); );
String mciIoPackage = BASE_PACKAGE + "." + mciGroupPath.replace("/", ".");
serviceImplContent = serviceImplContent
.replace("import " + mciIoPackage + ".io." + interfaceId + "_I;",
"import " + mciIoPackage + ".io." + interfaceId + "_I;\nimport " + mciIoPackage + ".io." + interfaceId + "_O;")
.replace("Transfer<Object> resTransfer", "Transfer<" + interfaceId + "_O> resTransfer")
.replace("Object.class", interfaceId + "_O.class")
.replace("response.setResultCode(\"SUCCESS\");",
"if (resTransfer.getBody() != null) {\n response = converter.toResponse(resTransfer.getBody());\n }\n response.setResultCode(\"SUCCESS\");");
} else { } else {
serviceImplContent = """ serviceImplContent = "HTTP".equalsIgnoreCase(routingType)
? httpUseCaseImplContent(bizPackage, baseName, interfaceId, author, createDate)
: """
package %s.usecase.impl; package %s.usecase.impl;
import %s.dto.%sRequest; import %s.dto.%sRequest;
@@ -383,9 +424,16 @@ public class ToolScaffolder {
private final %sConverter converter; private final %sConverter converter;
@Override @Override
public Object execute(%sRequest req) { public %sResponse execute(%sRequest req) {
// %sLegacyRequest legacyRequest = converter.toLegacyRequest(req); // %sLegacyRequest legacyRequest = converter.toLegacyRequest(req);
return executeLegacy("%s", "%s", req); // Or pass legacyRequest Object legacyResponse = executeLegacy("%s", "%s", req); // Or pass legacyRequest
if (legacyResponse instanceof %sResponse response) {
return response;
}
%sResponse response = new %sResponse();
response.setResultCode("SUCCESS");
response.setResultMessage("Legacy call completed.");
return response;
} }
} }
""".formatted( """.formatted(
@@ -402,6 +450,10 @@ public class ToolScaffolder {
baseName, baseName,
baseName, baseName,
baseName, baseName,
baseName,
baseName,
baseName,
baseName,
routingType, interfaceId routingType, interfaceId
); );
} }
@@ -441,6 +493,7 @@ public class ToolScaffolder {
private String content; private String content;
} }
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId); """.formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
mciReqContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_I", inputFields, author, createDate);
Files.writeString(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent); Files.writeString(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent);
String mciResContent = """ String mciResContent = """
@@ -467,6 +520,7 @@ public class ToolScaffolder {
// TODO: Add response fields here // TODO: Add response fields here
} }
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId); """.formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
mciResContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_O", outputFields, author, createDate);
Files.writeString(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent); Files.writeString(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent);
String converterContent = """ String converterContent = """
@@ -518,6 +572,7 @@ public class ToolScaffolder {
baseName, interfaceId, baseName, interfaceId,
baseName, interfaceId baseName, interfaceId
); );
converterContent = mciConverterContent(bizPackage, baseName, mciGroupPath.replace("/", "."), interfaceId);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent); Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n"); log.append("\n=========================================\n");
@@ -559,7 +614,7 @@ public class ToolScaffolder {
public class Mci%sClient { public class Mci%sClient {
private final AxhubMciComponent mci; private final AxhubMciComponent mci;
public Transfer<Object> callTo(String interfaceId, String dummy, Object mciReq, Class<Object> resType) throws Exception { public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {
return mci.callTo(interfaceId, dummy, mciReq, resType); return mci.callTo(interfaceId, dummy, mciReq, resType);
} }
} }
@@ -604,6 +659,7 @@ public class ToolScaffolder {
private String content; private String content;
} }
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
legacyReqContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyRequest", inputFields, author, createDate, true);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent); Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent);
String legacyResContent = """ String legacyResContent = """
@@ -630,6 +686,7 @@ public class ToolScaffolder {
// TODO: Add legacy response fields here // TODO: Add legacy response fields here
} }
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
legacyResContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyResponse", outputFields, author, createDate, true);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent); Files.writeString(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent);
String converterContent = """ String converterContent = """
@@ -679,6 +736,7 @@ public class ToolScaffolder {
bizPackage, baseName, author, createDate, createDate, author, bizPackage, baseName, author, createDate, createDate, author,
baseName, baseName, baseName, baseName, baseName, baseName, baseName baseName, baseName, baseName, baseName, baseName, baseName, baseName
); );
converterContent = legacyConverterContent(bizPackage, baseName);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent); Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n"); log.append("\n=========================================\n");
@@ -732,6 +790,17 @@ public class ToolScaffolder {
log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n"); log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n");
} }
Path mockResponsePath = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mock-responses", toolName + ".json"));
Files.createDirectories(mockResponsePath.getParent());
Files.writeString(mockResponsePath, mockResponseContent(outputFields));
Path generatedTestDir = rootDir.resolve(Paths.get(moduleName, "src/test/java/io/shinhanlife/dap/mcc/biz", group.toLowerCase(), "usecase"));
Files.createDirectories(generatedTestDir);
Path generatedTestPath = generatedTestDir.resolve(baseName + "UseCaseTest.java");
Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName));
log.append("[Mock Response] ").append(mockResponsePath).append("\\n");
log.append("[Unit Test] ").append(generatedTestPath).append("\\n");
log.append("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\n");
log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n"); log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n");
return log.toString(); return log.toString();
@@ -744,6 +813,223 @@ public class ToolScaffolder {
.toLowerCase(Locale.ROOT); .toLowerCase(Locale.ROOT);
} }
private static String dtoContent(String packageName, String className, List<FieldDefinition> fields,
String author, String createDate, boolean request) {
String body = fieldLines(fields, request ? Set.of() : Set.of("resultCode", "resultMessage"));
if (!request) {
body = " private String resultCode;\n\n private String resultMessage;\n" + body;
}
return """
package %s;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %s {
%s}
""".formatted(packageName, className, body);
}
private static String mciIoContent(String packageSuffix, String className, List<FieldDefinition> fields,
String author, String createDate) {
return """
package %s.%s.io;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class %s {
%s}
""".formatted(BASE_PACKAGE, packageSuffix, className, fieldLines(fields));
}
private static String httpUseCaseImplContent(String bizPackage, String baseName, String interfaceId, String author, String createDate) {
return """
package %s.usecase.impl;
import %s.converter.%sConverter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.legacy.%sLegacyRequest;
import %s.legacy.%sLegacyResponse;
import %s.usecase.%sUseCase;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpDomain;
import io.shinhanlife.dap.mcc.usecase.AbstractMcpToolUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* HTTP Tool implementation. Calls the Glow HTTP adapter through AxhubHttpComponent.
* Configure the target domain in AxhubHttpDomain and glow.communication.http.api-list before use.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class %sUseCaseImpl extends AbstractMcpToolUseCase implements %sUseCase {
private final %sConverter converter;
private final AxhubHttpComponent http;
@Override
public %sResponse execute(%sRequest req) {
%sLegacyRequest legacyRequest = converter.toLegacyRequest(req);
%sLegacyResponse legacyResponse = http.call(
AxhubHttpDomain.SAMPLE,
"/%s",
legacyRequest,
%sLegacyResponse.class
);
%sResponse response = converter.toResponse(legacyResponse);
response.setResultCode("SUCCESS");
response.setResultMessage("HTTP API call completed.");
return response;
}
}
""".formatted(
bizPackage, bizPackage, baseName, bizPackage, baseName, bizPackage, baseName,
bizPackage, baseName, bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName, baseName, baseName, baseName, baseName,
interfaceId, baseName, baseName);
}
private static String mciConverterContent(String bizPackage, String baseName, String mciPackage, String interfaceId) {
return """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.%s.io.%s_I;
import %s.%s.io.%s_O;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface %sConverter {
%s_I toLegacyRequest(%sRequest request);
%sRequest toRequest(%s_I mciRequest);
%sResponse toResponse(%s_O mciRes);
}
""".formatted(bizPackage, bizPackage, baseName, bizPackage, baseName,
BASE_PACKAGE, mciPackage, interfaceId, BASE_PACKAGE, mciPackage, interfaceId,
baseName, interfaceId, baseName, baseName, interfaceId, baseName, interfaceId);
}
private static String legacyConverterContent(String bizPackage, String baseName) {
return """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.legacy.%sLegacyRequest;
import %s.legacy.%sLegacyResponse;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface %sConverter {
%sLegacyRequest toLegacyRequest(%sRequest request);
%sRequest toRequest(%sLegacyRequest legacyRequest);
%sResponse toResponse(%sLegacyResponse legacyResponse);
}
""".formatted(
bizPackage, bizPackage, baseName, bizPackage, baseName,
bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName, baseName, baseName, baseName, baseName);
}
private static String fieldLines(List<FieldDefinition> fields) {
return fieldLines(fields, Set.of());
}
private static String fieldLines(List<FieldDefinition> fields, Set<String> excludedNames) {
StringBuilder source = new StringBuilder();
Set<String> generatedNames = new LinkedHashSet<>();
for (FieldDefinition field : fields == null ? List.<FieldDefinition>of() : fields) {
if (field == null || field.name() == null || field.name().isBlank()) {
continue;
}
String fieldName = field.name().trim();
if (excludedNames.contains(fieldName) || !generatedNames.add(fieldName)) {
continue;
}
String type = supportedType(field.type());
String description = field.description() == null ? "" : field.description().replace("\"", "\\\"");
String example = field.example() == null ? "" : field.example().replace("\"", "\\\"");
source.append(" @Schema(description = \"").append(description).append("\", example = \"")
.append(example).append("\"");
if (field.required()) {
source.append(", requiredMode = Schema.RequiredMode.REQUIRED");
}
source.append(")\n private ").append(type).append(' ').append(fieldName).append(";\n\n");
}
return source.toString();
}
private static String supportedType(String type) {
return switch (type == null ? "String" : type) {
case "String", "Integer", "Long", "Double", "Boolean", "BigDecimal" -> type;
default -> throw new IllegalArgumentException("Unsupported field type: " + type);
};
}
private static String mockResponseContent(List<FieldDefinition> outputFields) {
StringBuilder json = new StringBuilder("{\n");
List<FieldDefinition> fields = outputFields == null ? List.of() : outputFields;
boolean first = true;
for (FieldDefinition field : fields) {
if (field.name() == null || field.name().isBlank()) {
continue;
}
if (!first) {
json.append(",\n");
}
json.append(" \"").append(jsonEscape(field.name())).append("\" : ")
.append(mockValue(field));
first = false;
}
return json.append("\n}\n").toString();
}
private static String mockValue(FieldDefinition field) {
if (field.example() == null || field.example().isBlank()) {
return "null";
}
return switch (supportedType(field.type())) {
case "Integer", "Long", "Double", "BigDecimal" -> field.example();
case "Boolean" -> Boolean.parseBoolean(field.example()) ? "true" : "false";
default -> "\"" + jsonEscape(field.example()) + "\"";
};
}
private static String jsonEscape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String useCaseTestContent(String bizPackage, String baseName) {
return """
package %s.usecase;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import org.junit.jupiter.api.Test;
class %sUseCaseTest {
@Test
void createsToolRequestAndResponseDtos() {
assertNotNull(new %sRequest());
assertNotNull(new %sResponse());
}
}
""".formatted(bizPackage, bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName);
}
private static String toToolName(String moduleName, String group, String baseName) { private static String toToolName(String moduleName, String group, String baseName) {
String moduleDirectory = Path.of(moduleName).getFileName().toString(); String moduleDirectory = Path.of(moduleName).getFileName().toString();
String pod = moduleDirectory.startsWith("dap-was-") String pod = moduleDirectory.startsWith("dap-was-")
@@ -756,7 +1042,7 @@ public class ToolScaffolder {
String[] words = normalizedName.split("\\s+"); String[] words = normalizedName.split("\\s+");
String service = words[0]; String service = words[0];
String action = words.length == 1 ? "execute" : words[words.length - 1]; String action = words.length == 1 ? "execute" : words[words.length - 1];
return "%s.%s.%s.%s".formatted( return "%s_%s_%s_%s".formatted(
pod.toLowerCase(Locale.ROOT), pod.toLowerCase(Locale.ROOT),
group.toLowerCase(Locale.ROOT), group.toLowerCase(Locale.ROOT),
service, service,

View File

@@ -2,14 +2,13 @@ package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
// removed McpOutputSchema import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import java.io.InputStream; import java.io.InputStream;
import java.util.Map; import java.util.Map;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import io.shinhanlife.dap.lib.annotation.ToolHint; /** Resolves MCP Tool schemas from resources or DTO metadata. */
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
public class ToolSchemaResolver { public class ToolSchemaResolver {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -18,7 +17,8 @@ public class ToolSchemaResolver {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public Map<String, Object> resolve(org.springframework.ai.mcp.annotation.McpTool function, ToolHint hint, Class<?> requestType) { public Map<String, Object> resolve(org.springaicommunity.mcp.annotation.McpTool function,
ToolHint hint, Class<?> requestType) {
if (hint != null && !hint.inputSchemaResource().isBlank()) { if (hint != null && !hint.inputSchemaResource().isBlank()) {
return loadResource(hint.inputSchemaResource()); return loadResource(hint.inputSchemaResource());
} }
@@ -26,12 +26,11 @@ public class ToolSchemaResolver {
} }
/** /**
* Resolves an explicitly declared response schema. * Resolves a response schema only when it is explicitly declared.
* Response schemas are opt-in so existing tools keep their current response behavior. * A JSON resource has precedence over a DTO marker annotation.
* ToolHint.outputSchemaResource()가 있으면 classpath JSON 파일에서 로드하고,
* 없으면 responseType DTO를 분석하여 자동 생성합니다.
*/ */
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType, ToolHint hint) { public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function,
Class<?> responseType, ToolHint hint) {
if (hint != null && !hint.outputSchemaResource().isBlank()) { if (hint != null && !hint.outputSchemaResource().isBlank()) {
return loadResource(hint.outputSchemaResource()); return loadResource(hint.outputSchemaResource());
} }
@@ -39,16 +38,16 @@ public class ToolSchemaResolver {
} }
/** /**
* Resolves an explicitly declared response schema. * Generates a response schema only for DTOs marked with {@link McpOutputSchema}.
* Response schemas are opt-in so existing tools keep their current response behavior.
*/ */
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType) { public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function,
// Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵 Class<?> responseType) {
if (responseType == null if (responseType == null
|| responseType == Object.class || responseType == Object.class
|| Map.class.isAssignableFrom(responseType) || Map.class.isAssignableFrom(responseType)
|| responseType == Void.class || responseType == Void.class
|| responseType == void.class) { || responseType == void.class
|| !responseType.isAnnotationPresent(McpOutputSchema.class)) {
return Map.of(); return Map.of();
} }
return JsonSchemaGenerator.generateSchema(responseType); return JsonSchemaGenerator.generateSchema(responseType);
@@ -57,8 +56,8 @@ public class ToolSchemaResolver {
/** /**
* Retained for callers that use only explicit output schemas. * Retained for callers that use only explicit output schemas.
*/ */
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function) { public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function) {
return resolveOutput(function, null); return Map.of();
} }
private Map<String, Object> loadResource(String location) { private Map<String, Object> loadResource(String location) {
@@ -76,12 +75,4 @@ public class ToolSchemaResolver {
throw new IllegalStateException("Failed to load MCP schema resource: " + location, e); throw new IllegalStateException("Failed to load MCP schema resource: " + location, e);
} }
} }
private Map<String, Object> parse(String schema, String source) {
try {
return objectMapper.readValue(schema, new TypeReference<>() { });
} catch (Exception e) {
throw new IllegalStateException("Failed to parse MCP input schema from " + source, e);
}
}
} }

View File

@@ -28,7 +28,7 @@ import java.util.regex.Pattern;
public final class McpToolNameValidator { public final class McpToolNameValidator {
private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\""); private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\"");
private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$"); private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-zA-Z0-9_-]{1,128}$");
private McpToolNameValidator() { private McpToolNameValidator() {
} }
@@ -139,7 +139,7 @@ public final class McpToolNameValidator {
} }
private static String buildInvalidNameMessage(List<Map.Entry<String, List<ToolDeclaration>>> invalidNames) { private static String buildInvalidNameMessage(List<Map.Entry<String, List<ToolDeclaration>>> invalidNames) {
StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected pod.domain.service.action using lowercase letters, digits, or hyphens."); StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected 1-128 characters using letters, digits, underscores, or hyphens.");
for (Map.Entry<String, List<ToolDeclaration>> invalidName : invalidNames) { for (Map.Entry<String, List<ToolDeclaration>> invalidName : invalidNames) {
message.append("\n\n").append(invalidName.getKey()); message.append("\n\n").append(invalidName.getKey());
invalidName.getValue().stream() invalidName.getValue().stream()

View File

@@ -8,10 +8,8 @@ import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion; import com.networknt.schema.SpecificationVersion;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.stereotype.Component;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */ /** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */
@Component
public class ToolArgumentSchemaValidator { public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;

View File

@@ -1,42 +1,14 @@
package io.shinhanlife.dap.mcc.presentation; package io.shinhanlife.dap.mcc.presentation;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
/** import io.shinhanlife.dap.lib.mcp.ToolExecutionResult;
* @package io.shinhanlife.dap.mcc.presentation import io.shinhanlife.dap.lib.mcp.McpToolExecutionService;
* @className BusinessToolController
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import org.springframework.ai.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender; import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.ClassUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -45,26 +17,20 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j /** Legacy REST adapter for Tool Pod execution. */
@RestController @RestController
@RequestMapping("/") @RequestMapping("/")
@RequiredArgsConstructor @RequiredArgsConstructor
public class BusinessToolController { public class BusinessToolController {
private final ApplicationContext applicationContext;
private final ObjectMapper objectMapper;
private final McpProperties mcpProperties;
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender; private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator; private final McpToolExecutionService toolExecutionService;
private final ToolSchemaResolver toolSchemaResolver;
// 내부 조회용 로컬 Tool 목록 엔드포인트
@GetMapping("/mcp/api/v1/tools/local") @GetMapping("/mcp/api/v1/tools/local")
public List<ToolMetadata> getLocalTools() { public List<ToolMetadata> getLocalTools() {
return toolRegistryHeartbeatSender.getAllScannedTools(); return toolRegistryHeartbeatSender.getAllScannedTools();
} }
// 순수 REST 기반 동적 라우팅 엔드포인트
@PostMapping("/mcp/{name}") @PostMapping("/mcp/{name}")
public ResponseEntity<?> executeDynamicTool( public ResponseEntity<?> executeDynamicTool(
@PathVariable("name") String functionName, @PathVariable("name") String functionName,
@@ -73,173 +39,12 @@ public class BusinessToolController {
@RequestHeader(value = "request-id", required = false) String requestId, @RequestHeader(value = "request-id", required = false) String requestId,
@RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId, @RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId,
@RequestBody(required = false) Map<String, Object> arguments) { @RequestBody(required = false) Map<String, Object> arguments) {
ToolExecutionResult result = toolExecutionService.execute(
String finalRequestId = headerRequestId; functionName,
new McpRequestHeaders(headerRequestId, traceId, requestId, encryptedEmployeeId),
log.info(" [Tool] IN - trace-id: {}, request-id: {}", traceId, requestId); arguments);
log.info(" [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName); ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode());
if (arguments != null) { result.headers().forEach(response::header);
try { return response.body(result.body());
log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(arguments));
} catch (Exception e) {
log.info(" [Tool] 호출 파라미터: {}", arguments);
}
}
Object targetBean = null;
Method targetMethod = null;
McpTool targetFunctionAnnotation = null;
// McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpTool을 찾습니다.
Map<String, Object> allBeans = applicationContext.getBeansOfType(Object.class);
outerLoop:
for (Object bean : allBeans.values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
for (Method targetMethodOfClass : targetClass.getDeclaredMethods()) {
McpTool mcpFunc = AnnotationUtils.findAnnotation(targetMethodOfClass, McpTool.class);
if (mcpFunc != null) {
String baseName = mcpFunc.name();
String expectedName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + baseName
: baseName;
if (expectedName.equals(functionName) || baseName.equals(functionName)) {
targetBean = bean;
try {
targetMethod = bean.getClass().getMethod(targetMethodOfClass.getName(), targetMethodOfClass.getParameterTypes());
} catch (NoSuchMethodException e) {
targetMethod = targetMethodOfClass;
}
targetFunctionAnnotation = mcpFunc;
break outerLoop;
}
}
}
}
if (targetBean == null || targetMethod == null) {
List<String> availableFunctions = new ArrayList<>();
for (Object bean : allBeans.values()) {
Class<?> targetCls = AopUtils.getTargetClass(bean);
for (Method m : targetCls.getDeclaredMethods()) {
McpTool func = AnnotationUtils.findAnnotation(m, McpTool.class);
if (func != null) {
String baseName = func.name();
String expName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + baseName : baseName;
availableFunctions.add(expName + " (in " + targetCls.getSimpleName() + ")");
}
}
}
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}. 현재 스캔된 툴 메서드 목록: {}", functionName, availableFunctions);
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "404");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "TOOL_NOT_FOUND");
errorBody.put("message", "실행할 함수를 찾을 수 없습니다: " + functionName);
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(404).body(errorBody);
}
// (기존 차단 로직 제거됨)
// 2. 파라미터 유효성 검증 (JSON Schema)
if (targetMethod.getParameterCount() > 0) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
io.shinhanlife.dap.lib.annotation.ToolHint hint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, hint, paramType);
List<Error> errors = toolArgumentSchemaValidator.validate(inputSchema, arguments);
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>();
for (Error validationError : errors) {
errorMessages.add(validationError.getMessage());
}
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "INVALID_PARAM");
errorBody.put("message", "파라미터 유효성 검증 실패");
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(422).body(errorBody);
}
} catch (Exception e) {
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
}
}
}
log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName());
try {
// 3. DTO 파라미터 자동 매핑 (Map -> DTO)
Object invokeArgument = arguments;
if (targetMethod.getParameterCount() > 0 && arguments != null) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
invokeArgument = objectMapper.convertValue(arguments, paramType);
log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName());
}
}
long startTime = System.currentTimeMillis();
Object methodResult = null;
if (targetMethod.getParameterCount() == 0) {
methodResult = targetMethod.invoke(targetBean);
} else {
methodResult = targetMethod.invoke(targetBean, invokeArgument);
}
io.shinhanlife.dap.lib.annotation.ToolHint outputHint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType(), outputHint);
if (!outputSchema.isEmpty()) {
List<Error> outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult);
if (!outputErrors.isEmpty()) {
log.error("[Tool] Output schema validation failed. tool={}, errors={}",
functionName, outputErrors);
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "INVALID_TOOL_RESPONSE");
errorBody.put("message", "Tool response does not match its output schema");
if (finalRequestId != null) {
errorBody.put("request_id", finalRequestId);
}
return ResponseEntity.internalServerError().body(errorBody);
}
}
long elapsed = System.currentTimeMillis() - startTime;
// 5. 결과 반환 (순수 REST 응답)
try {
log.info("[Tool -> MCP Gateway] Output Schema Result: {}", objectMapper.writeValueAsString(methodResult));
} catch (Exception e) {
log.info("[Tool -> MCP Gateway] Output Schema Result: {}", methodResult);
}
log.info(" [Tool] OUT - trace-id: {}, request-id: {}", traceId, requestId);
ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok();
if (traceId != null) responseBuilder.header("trace-id", traceId);
if (requestId != null) responseBuilder.header("request-id", requestId);
return responseBuilder.body(methodResult);
} catch (Exception e) {
log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage());
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "500");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "TOOL_ERROR");
errorBody.put("message", "Tool execution failed");
errorDetails.clear(); // Hide details for upstream errors
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(502).body(errorBody);
}
} }
} }

View File

@@ -0,0 +1,6 @@
package io.shinhanlife.glow.communication;
/** Minimal Glow communication contract used by the temporary compatibility layer. */
public interface ICommunication<I, O> {
O sync(I request);
}

View File

@@ -0,0 +1,98 @@
package io.shinhanlife.glow.communication.module.http.component;
import io.shinhanlife.glow.communication.ICommunication;
import io.shinhanlife.glow.communication.module.http.dto.HttpBody;
import io.shinhanlife.glow.communication.module.http.dto.HttpHeader;
import io.shinhanlife.glow.communication.module.http.dto.HttpTransfer;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Temporary compatibility implementation of the internal Glow HTTP component.
* Replace this class with the official Glow HTTP JAR when it is supplied.
*/
@Component
public class GlowHttpComponent implements ICommunication<HttpTransfer<?>, ResponseEntity<HttpBody>> {
private final RestClient restClient;
public GlowHttpComponent(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.build();
}
@Override
public ResponseEntity<HttpBody> sync(HttpTransfer<?> request) {
if (request == null || request.getHeader() == null || request.getMethod() == null
|| !StringUtils.hasText(request.getDomain())) {
throw new IllegalArgumentException("Glow HTTP request header, domain, and method are required.");
}
if (HttpMethod.GET.equals(request.getMethod())) {
return get(request);
}
if (HttpMethod.POST.equals(request.getMethod())) {
return post(request);
}
if (HttpMethod.PUT.equals(request.getMethod())) {
return put(request);
}
if (HttpMethod.DELETE.equals(request.getMethod())) {
return delete(request);
}
throw new IllegalArgumentException("Unsupported HTTP method: " + request.getMethod());
}
private ResponseEntity<HttpBody> get(HttpTransfer<?> request) {
return toHttpBody(restClient.get().uri(buildUri(request, true))
.headers(headers -> applyHeaders(headers, request.getHeader()))
.retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> post(HttpTransfer<?> request) {
return toHttpBody(restClient.post().uri(buildUri(request, false))
.contentType(contentType(request)).headers(headers -> applyHeaders(headers, request.getHeader()))
.body(request.getBody()).retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> put(HttpTransfer<?> request) {
return toHttpBody(restClient.put().uri(buildUri(request, false))
.contentType(contentType(request)).headers(headers -> applyHeaders(headers, request.getHeader()))
.body(request.getBody()).retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> delete(HttpTransfer<?> request) {
return toHttpBody(restClient.delete().uri(buildUri(request, true))
.headers(headers -> applyHeaders(headers, request.getHeader()))
.retrieve().toEntity(String.class));
}
private String buildUri(HttpTransfer<?> request, boolean includeQueryParameters) {
String uri = request.getDomain() + (request.getUri() == null ? "" : request.getUri());
if (!includeQueryParameters || !(request.getBody() instanceof Map<?, ?> parameters)) {
return uri;
}
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uri);
parameters.forEach((key, value) -> { if (key != null && value != null) builder.queryParam(String.valueOf(key), value); });
return builder.build().encode().toUriString();
}
private MediaType contentType(HttpTransfer<?> request) {
return request.getContentType() == null ? MediaType.APPLICATION_JSON : request.getContentType();
}
private void applyHeaders(HttpHeaders target, HttpHeader source) {
target.setAccept(List.of(MediaType.APPLICATION_JSON));
source.getValues().forEach((name, value) -> { if (StringUtils.hasText(name) && StringUtils.hasText(value)) target.set(name, value); });
}
private ResponseEntity<HttpBody> toHttpBody(ResponseEntity<String> response) {
return new ResponseEntity<>(new HttpBody(response.getBody()), response.getHeaders(), response.getStatusCode());
}
}

View File

@@ -0,0 +1,5 @@
package io.shinhanlife.glow.communication.module.http.dto;
/** Raw body returned by the Glow HTTP transport. */
public record HttpBody(String content) {
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.glow.communication.module.http.dto;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/** Glow HTTP request headers and timeout metadata. */
@Getter
@Setter
public class HttpHeader {
private Map<String, String> values = new LinkedHashMap<>();
private int readTimeout;
public void set(String name, String value) {
values.put(name, value);
}
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.glow.communication.module.http.dto;
import lombok.Builder;
import lombok.Getter;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
/** Glow HTTP request envelope. Use HttpTransfer.http() to build a request. */
@Getter
@Builder(builderMethodName = "http")
public class HttpTransfer<T> {
private final HttpHeader header;
private final String domain;
private final String uri;
private final HttpMethod method;
private final MediaType contentType;
private final Class<?> responseEntity;
private final T body;
}

View File

@@ -1,5 +1,19 @@
# Glow 개발 환경 확장 설정 # Glow 개발 환경 설정
spring: spring:
config: config:
activate: activate:
on-profile: dev on-profile: dev
glow:
communication:
common:
env-type: D
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST:https://dev-ichmci.shinhanlife.co.kr}
port: ${GLOW_COMMUNICATION_MCI_PORT:26160}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST:http://host.docker.internal}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT:8080}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST:tcp://host.docker.internal}
port: ${GLOW_COMMUNICATION_EAI_PORT:9999}

View File

@@ -1,5 +1,19 @@
# Glow 로컬 환경 확장 설정 # Glow 로컬 환경 설정
spring: spring:
config: config:
activate: activate:
on-profile: local on-profile: local
glow:
communication:
common:
env-type: D
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_MCI_PORT:8080}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT:8080}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_EAI_PORT:8080}

View File

@@ -1,5 +1,19 @@
# Glow 운영 환경 확장 설정 # Glow 운영 환경 설정
spring: spring:
config: config:
activate: activate:
on-profile: prod on-profile: prod
glow:
communication:
common:
env-type: P
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST}
port: ${GLOW_COMMUNICATION_MCI_PORT}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST}
port: ${GLOW_COMMUNICATION_EAI_PORT}

View File

@@ -1,5 +1,19 @@
# Glow 테스트 환경 확장 설정 # Glow 테스트 환경 설정
spring: spring:
config: config:
activate: activate:
on-profile: test on-profile: test
glow:
communication:
common:
env-type: T
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST}
port: ${GLOW_COMMUNICATION_MCI_PORT}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST}
port: ${GLOW_COMMUNICATION_EAI_PORT}

View File

@@ -1,9 +1,5 @@
# ============================================================================== # AX HUB Tool Pod 공통 Glow Framework 설정입니다.
# [AXHub 공통 환경 설정 파일] (Glow Framework 연동용) # Tool Pod의 application-{profile}.yml에서 이 파일과 환경별 Glow 설정을 함께 import합니다.
# 이 파일은 axhub-tool-core에 위치하며, 각 툴 파드들이 상속받아 사용합니다.
# ==============================================================================
# 1. 로깅(Logging) 공통 설정
logging: logging:
level: level:
root: INFO root: INFO
@@ -13,42 +9,38 @@ logging:
pattern: pattern:
console: "[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] %logger{36} - %msg%n" console: "[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] %logger{36} - %msg%n"
# 2. Redis 공통 설정 (Gateway 연동 및 캐싱)
spring: spring:
data: data:
redis: redis:
host: 127.0.0.1 # TODO: 실제 Redis 망 IP로 변경 host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
port: 6379 # TODO: 실제 Redis 포트로 변경 port: ${SPRING_DATA_REDIS_PORT:6379}
password: "" # TODO: 실제 비밀번호 입력 password: ${SPRING_DATA_REDIS_PASSWORD:}
# 3. 신한라이프 Glow Framework 통신 (MCI / EAI) 설정
glow: glow:
communication: #Communication 설정 communication:
common: http:
env-type: D # 대내표준 헤더의 환경 타입정보 (D: 개발, T: 테스트, P: 운영) connection-timeout: 5
http: # HTTP 비표준 통신 설정 read-timeout: 5
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위) # HTTP Tool target catalog. Replace or add entries after the business endpoint is agreed.
read-timeout: 5 # 읽기 타임아웃 시간 (초 단위) api-list:
mci: # 개발계 대내 MCI 통신 설정 - name: sample
host: https://dev-ichmci.shinhanlife.co.kr domain: ${AXHUB_SAMPLE_HTTP_DOMAIN:http://localhost:8099}
port: 26160 path: ""
method: GET
biz-pod: false
mci:
uri: /ntl_mci/dap_rcv uri: /ntl_mci/dap_rcv
receive-uri: /itrf/mciReceive receive-uri: /itrf/mciReceive
connection-timeout: 300 connection-timeout: 300
read-timeout: 300 read-timeout: 300
encoding: UTF-8 encoding: UTF-8
extmci: # 대외MCI 통신 설정 extmci:
host: http://host.docker.internal # 대외MCI 호스트 주소 uri: /extmci
port: 8080 # 대외MCI 포트 번호 json-uri: /extmciJson
uri: /extmci # 대외MCI URI 정보 receive-uri: /app/extMciReceive
json-uri: /extmciJson # 대외MCI JSON방식 URI 정보 connection-timeout: 5
receive-uri: /app/extMciReceive # 대외MCI 수신 URI read-timeout: 30
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위) encoding: EUC-KR
read-timeout: 30 # 읽기 타임아웃 시간 (초 단위)
encoding: EUC-KR # 대외MCI 인코딩 정보
eai: # EAI 통신 설정
host: tcp://host.docker.internal # EAI 호스트 주소 (TODO: 실제 주소로 변경)
port: 9999 # EAI 포트 번호
websocket: websocket:
endpoint: "/ws-glow" endpoint: /ws-glow
allowed-origins: "*" allowed-origins: "*"

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.lib.config;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.client.RestClient;
class AxhubHttpConfigurationTest {
@Test
void registersGlowHttpComponentFromDapLibConfiguration() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfiguration.class)) {
assertThat(context.getBean(GlowHttpComponent.class)).isNotNull();
}
}
@Configuration
@Import(AxhubHttpConfiguration.class)
static class TestConfiguration {
@Bean
RestClient.Builder restClientBuilder() {
return RestClient.builder();
}
}
}

View File

@@ -3,14 +3,20 @@ package io.shinhanlife.dap.lib.config;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class ToolSchemaConfigurationTest { class ToolSchemaConfigurationTest {
@Test @Test
void registersToolSchemaResolver() { void providesToolArgumentSchemaValidatorWithoutComponentScanning() {
ToolSchemaConfiguration configuration = new ToolSchemaConfiguration(); try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(ObjectMapper.class);
context.register(ToolSchemaConfiguration.class);
context.refresh();
assertNotNull(configuration.toolSchemaResolver(new ObjectMapper())); assertNotNull(context.getBean(ToolArgumentSchemaValidator.class));
}
} }
} }

View File

@@ -0,0 +1,43 @@
package io.shinhanlife.dap.lib.integration.http.component;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class AxhubHttpComponentTest {
@Test
void callResolvesDomainBuildsGlowTransferAndDeserializesJsonResponse() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
GlowHttpComponent glowHttpComponent = new GlowHttpComponent(builder);
AxhubHttpProperties properties = new AxhubHttpProperties();
properties.setApiList(List.of(new AxhubHttpProperties.ApiDefinition(
"sample", "https://api.example.test", "/v1", HttpMethod.GET, false)));
AxhubHttpComponent component = new AxhubHttpComponent(
glowHttpComponent, new ObjectMapper(), new GlowCommunicationProperties(), properties);
server.expect(requestTo("https://api.example.test/v1/status"))
.andExpect(header("X-ANONYMOUS-REQ", "AXHUB-TOOL"))
.andRespond(withSuccess("{\"status\":\"OK\"}", APPLICATION_JSON));
SampleResponse response = component.call(AxhubHttpDomain.SAMPLE, "/status", null, SampleResponse.class);
assertThat(response.status()).isEqualTo("OK");
server.verify();
}
record SampleResponse(String status) {
}
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.dap.lib.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.shinhanlife.dap.lib.config.McpProperties;
import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.context.support.StaticApplicationContext;
class McpToolMethodRegistryTest {
@Test
void indexesAnnotatedToolDuringInitialization() {
StaticApplicationContext context = contextWith(new EchoTool());
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
registry.initialize();
McpToolMethodRegistry.RegisteredTool tool = registry.find("oth_cmm_echo_search");
assertNotNull(tool);
assertEquals("execute", tool.method().getName());
assertEquals("oth_cmm_echo_search", tool.annotation().name());
}
@Test
void rejectsDuplicateToolNamesDuringInitialization() {
StaticApplicationContext context = contextWith(new EchoTool(), new DuplicateEchoTool());
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
assertThrows(IllegalStateException.class, registry::initialize);
}
private StaticApplicationContext contextWith(Object... tools) {
StaticApplicationContext context = new StaticApplicationContext();
for (int index = 0; index < tools.length; index++) {
context.getBeanFactory().registerSingleton("tool" + index, tools[index]);
}
context.refresh();
return context;
}
static class EchoTool {
@McpTool(name = "oth_cmm_echo_search")
public String execute(String request) {
return request;
}
}
static class DuplicateEchoTool {
@McpTool(name = "oth_cmm_echo_search")
public String execute(String request) {
return request;
}
}
}

View File

@@ -0,0 +1,62 @@
package io.shinhanlife.dap.lib.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpToolParam;
import org.springframework.context.support.StaticApplicationContext;
class McpToolExecutionServiceTest {
@Test
void returnsNotFoundWhenNoToolMatchesTheRequestedName() {
McpToolExecutionService service = serviceWith(new Object());
ToolExecutionResult result = service.execute("missing.cmm.tool.inquiry", null, Map.of());
assertEquals(404, result.statusCode());
assertEquals("TOOL_NOT_FOUND", ((Map<?, ?>) result.body()).get("code"));
}
@Test
void convertsArgumentsToDtoAndExecutesTheMatchedTool() {
McpToolExecutionService service = serviceWith(new EchoTool());
ToolExecutionResult result = service.execute("sample.cmm.value.echo",
new McpRequestHeaders("gateway-1", "trace-1", "request-1", "employee-1"), Map.of("value", "hello"));
assertEquals(200, result.statusCode());
assertEquals("hello", ((Map<?, ?>) result.body()).get("value"));
assertEquals("trace-1", result.headers().get("trace-id"));
assertEquals("request-1", result.headers().get("request-id"));
}
private McpToolExecutionService serviceWith(Object toolBean) {
ObjectMapper objectMapper = new ObjectMapper();
StaticApplicationContext context = new StaticApplicationContext();
context.getBeanFactory().registerSingleton("toolBean", toolBean);
context.refresh();
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
registry.initialize();
return new McpToolExecutionService(registry, objectMapper,
new ToolArgumentSchemaValidator(objectMapper), new ToolSchemaResolver(objectMapper));
}
static class EchoTool {
@McpTool(name = "sample.cmm.value.echo", description = "Echoes a value")
@ToolHint
public Map<String, Object> execute(EchoRequest request) {
return Map.of("value", request.value);
}
}
static class EchoRequest {
@McpToolParam(description = "Value", required = true)
private String value;
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
}
}

View File

@@ -0,0 +1,56 @@
package io.shinhanlife.dap.lib.mcp;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.context.ApplicationContext;
class ToolRegistryHeartbeatSenderTest {
@Test
void excludesRegisterFalseToolFromGatewayRegistrationTargets() throws Exception {
ApplicationContext applicationContext = mock(ApplicationContext.class);
when(applicationContext.getBeansOfType(Object.class)).thenReturn(Map.of("disabledTool", new DisabledTool()));
ToolRegistryHeartbeatSender sender = new ToolRegistryHeartbeatSender(
applicationContext, new ObjectMapper(), new McpProperties(), mock(ToolSchemaResolver.class));
setField(sender, "podUrl", "http://localhost:8084");
sender.init();
assertThat(sender.getAllScannedTools()).singleElement()
.extracting(tool -> tool.getIsRegistered())
.isEqualTo(false);
assertThat(registeredTools(sender)).isEmpty();
}
private void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
@SuppressWarnings("unchecked")
private List<?> registeredTools(ToolRegistryHeartbeatSender sender) throws Exception {
Field field = ToolRegistryHeartbeatSender.class.getDeclaredField("registeredTools");
field.setAccessible(true);
return (List<?>) field.get(sender);
}
static class DisabledTool {
@McpTool(name = "test_disabled_tool")
@ToolHint(register = false)
void execute() {
}
}
}

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.lib.util; package io.shinhanlife.dap.lib.util;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;

View File

@@ -0,0 +1,53 @@
package io.shinhanlife.dap.lib.util;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PodScaffolderTest {
@TempDir
Path root;
@Test
void generatesBuildGradleUsingRepositoryStandard() throws Exception {
PodScaffolder.scaffoldPod(root, "dap-was-sample", "8099", "sample", "tester", "2026.08.07");
String buildGradle = Files.readString(root.resolve("dap-was-sample/build.gradle"));
assertTrue(buildGradle.contains("id 'org.springframework.boot'"));
assertTrue(buildGradle.contains("Spring Boot 3.5.11"));
assertTrue(buildGradle.contains("implementation project(':dap-was-lib')"));
assertFalse(buildGradle.contains("compileOnly 'org.projectlombok:lombok"));
}
@Test
void generatesOthStandardRuntimeResources() throws Exception {
PodScaffolder.scaffoldPod(root, "dap-was-sample", "8099", "sample", "tester", "2026.08.07");
Path resources = root.resolve("dap-was-sample/src/main/resources");
String dockerfile = Files.readString(root.resolve("dap-was-sample/Dockerfile"));
assertTrue(dockerfile.contains("RUN apk add --no-cache tzdata"));
assertTrue(dockerfile.contains("ENV TZ=Asia/Seoul"));
assertTrue(dockerfile.contains("COPY dap-was-sample/build/libs/*-SNAPSHOT.jar app.jar"));
assertTrue(dockerfile.contains("EXPOSE 8099"));
assertTrue(Files.exists(resources.resolve("application-test.yml")));
assertTrue(Files.exists(resources.resolve("application-prod.yml")));
assertTrue(Files.readString(resources.resolve("application-test.yml"))
.contains("${AXHUB_GATEWAY_URL}"));
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("${AXHUB_TOOL_URL}"));
assertTrue(Files.readString(resources.resolve("application-local.yml"))
.contains("classpath:glow/application-glow-local.yml"));
assertTrue(Files.readString(resources.resolve("application-dev.yml"))
.contains("classpath:glow/application-glow-dev.yml"));
assertTrue(Files.readString(resources.resolve("application-test.yml"))
.contains("classpath:glow/application-glow-test.yml"));
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("classpath:glow/application-glow-prod.yml"));
}
}

View File

@@ -1,15 +1,21 @@
package io.shinhanlife.dap.lib.util; package io.shinhanlife.dap.lib.util;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ToolScaffolderTest { class ToolScaffolderTest {
@TempDir
Path root;
@Test @Test
void generatesManifestReadyToolAndOutputSchemaDto() throws Exception { void generatesSpringAiToolAndResponseDto() throws Exception {
String moduleName = "build/scaffold-manifest-test"; String moduleName = "build/scaffold-manifest-test";
ToolScaffolder.scaffold("claim search", "CLM0001", "청구 조회", "cmm", "HTTP", moduleName, ToolScaffolder.scaffold("claim search", "CLM0001", "청구 조회", "cmm", "HTTP", moduleName,
@@ -19,11 +25,10 @@ class ToolScaffolderTest {
String useCase = Files.readString(root.resolve("usecase/ClaimSearchUseCase.java")); String useCase = Files.readString(root.resolve("usecase/ClaimSearchUseCase.java"));
String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java")); String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java"));
assertTrue(useCase.contains("name = \"oth.cmm.claim.search\"")); assertTrue(useCase.contains("name = \"oth_cmm_claim_search\""));
assertTrue(useCase.contains("version = \"1.0.0\"")); assertTrue(useCase.contains("@ToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\")"));
assertTrue(useCase.contains("timeoutMillis = 300000L")); assertTrue(response.contains("private String resultCode;"));
assertTrue(response.contains("")); assertTrue(response.contains("private String resultMessage;"));
assertTrue(response.contains(""));
} }
@Test @Test
void usesWasModuleNameAsToolPodPrefix() throws Exception { void usesWasModuleNameAsToolPodPrefix() throws Exception {
@@ -36,6 +41,127 @@ class ToolScaffolderTest {
"src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/NotificationSendUseCase.java"); "src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/NotificationSendUseCase.java");
String useCase = Files.readString(useCasePath); String useCase = Files.readString(useCasePath);
assertTrue(useCase.contains("name = \"sms.cmm.notification.send\"")); assertTrue(useCase.contains("name = \"sms_cmm_notification_send\""));
}
@Test
void createsSchemaResourcesInToolSchemaCategoryDirectory() throws Exception {
String moduleName = root.resolve("dap-was-sample").toString();
ToolScaffolder.scaffold("claim search", "CLM0001", "청구 조회", "cmm", "HTTP", moduleName,
"tester", "2026.08.09", true, null,
"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json");
Path schemas = root.resolve("dap-was-sample/src/main/resources/tool-schemas/cmm");
assertTrue(Files.exists(schemas.resolve("claim-search-resource-input-schema.json")));
assertTrue(Files.exists(schemas.resolve("claim-search-resource-output-schema.json"))); String useCase = Files.readString(root.resolve("dap-was-sample/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchUseCase.java"));
assertTrue(useCase.contains("@ToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\","));
assertTrue(useCase.contains("inputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json\""));
assertTrue(useCase.contains("outputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json\""));
}
@Test
void generatesRequestDtoFromDeclaredFields() throws Exception {
String moduleName = root.resolve("dap-was-pay").toString();
ToolScaffolder.scaffold("search hr", "SHEARCH_01", "HR 조회", "pay", "MCI", moduleName,
"tester", "2026.08.09", true, "DFAG");
Path requestPath = root.resolve("dap-was-pay/src/main/java/io/shinhanlife/dap/mcc/biz/pay/dto/SearchHrRequest.java");
String request = Files.readString(requestPath);
assertTrue(request.contains("import io.swagger.v3.oas.annotations.media.Schema;"));
assertTrue(request.contains("@Schema(description = \"Search query\", example = \"example\")"));
assertTrue(request.contains("private String query;"));
assertFalse(request.contains("McpToolParam"));
Path implementationPath = root.resolve("dap-was-pay/src/main/java/io/shinhanlife/dap/mcc/biz/pay/usecase/impl/SearchHrUseCaseImpl.java");
String implementation = Files.readString(implementationPath);
assertTrue(implementation.contains("public SearchHrResponse execute(SearchHrRequest req)"));
assertTrue(implementation.contains("response.setResultCode(\"SUCCESS\")"));
}
@Test
void generatesMciToolFromDeclaredInputAndOutputFields() throws Exception {
String moduleName = root.resolve("dap-was-pay").toString();
List<ToolScaffolder.FieldDefinition> inputFields = List.of(
new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", "EMP10001", true),
new ToolScaffolder.FieldDefinition("page", "Integer", "Page number", "1", false));
List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong Gildong", true));
ToolScaffolder.scaffold("search hr", "SHEARCH_01", "HR search", "pay", "MCI", moduleName,
"tester", "2026.08.09", true, "DFAG", null, null, inputFields, outputFields);
Path sourceRoot = root.resolve("dap-was-pay/src/main/java/io/shinhanlife/dap/mcc");
String request = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrRequest.java"));
String response = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrResponse.java"));
String mciRequest = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_I.java"));
String mciResponse = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_O.java"));
String converter = Files.readString(sourceRoot.resolve("biz/pay/converter/SearchHrConverter.java")); String useCase = Files.readString(sourceRoot.resolve("biz/pay/usecase/SearchHrUseCase.java"));
String implementation = Files.readString(sourceRoot.resolve("biz/pay/usecase/impl/SearchHrUseCaseImpl.java"));
assertTrue(mciRequest.contains("package io.shinhanlife.dap.mcc.infra.itrf.mci.dfag.io;"), mciRequest); assertTrue(useCase.contains("@ToolHint(register = true, categoryKey = \"pay\", mappingId = \"SHEARCH_01\")"));
assertTrue(implementation.contains("import io.shinhanlife.dap.mcc.infra.itrf.mci.dfag.io.SHEARCH_01_O;"));
assertTrue(request.contains("private String employeeId;"));
assertTrue(request.contains("private Integer page;"));
assertFalse(request.contains("phoneNumber"));
assertTrue(response.contains("private String employeeName;"));
assertTrue(mciRequest.contains("private String employeeId;"));
assertTrue(mciResponse.contains("private String employeeName;"));
assertTrue(converter.contains("SearchHrResponse toResponse(SHEARCH_01_O mciRes);"));
}
@Test
void generatesMockResponseAndUnitTestSkeletonFromOutputFields() throws Exception {
String moduleName = root.resolve("dap-was-oth").toString();
List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("status", "String", "Claim status", "RECEIVED", true));
ToolScaffolder.scaffold("claim search", "CLM0001", "Claim search", "cmm", "MCI", moduleName,
"tester", "2026.08.10", true, null, null, null, List.of(), outputFields);
Path mockResponse = root.resolve("dap-was-oth/src/main/resources/mock-responses/oth_cmm_claim_search.json");
Path useCaseTest = root.resolve("dap-was-oth/src/test/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchUseCaseTest.java");
assertTrue(Files.exists(mockResponse));
assertTrue(Files.readString(mockResponse).contains("\"status\" : \"RECEIVED\""));
assertTrue(Files.exists(useCaseTest));
assertTrue(Files.readString(useCaseTest).contains("class ClaimSearchUseCaseTest"));
}
@Test
void generatesDtoPackageAndRemovesDuplicateResponseFields() throws Exception {
String moduleName = root.resolve("dap-was-http").toString();
List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("resultCode", "String", "API result", "SUCCESS", true),
new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong Gildong", false),
new ToolScaffolder.FieldDefinition("employeeName", "String", "Duplicate name", "Duplicate", false));
List<ToolScaffolder.FieldDefinition> inputFields = List.of(
new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", "EMP10001", true));
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName,
"tester", "2026.08.11", false, null, null, null, inputFields, outputFields);
Path dtoRoot = root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/dto");
String request = Files.readString(dtoRoot.resolve("EmployeeSearchRequest.java"));
String response = Files.readString(dtoRoot.resolve("EmployeeSearchResponse.java"));
assertTrue(request.contains("package io.shinhanlife.dap.mcc.biz.smp.dto;"), request);
assertTrue(response.contains("package io.shinhanlife.dap.mcc.biz.smp.dto;"), response);
assertTrue(response.indexOf("private String resultCode;") == response.lastIndexOf("private String resultCode;"), response);
assertTrue(response.indexOf("private String employeeName;") == response.lastIndexOf("private String employeeName;"), response);
String converter = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/converter/EmployeeSearchConverter.java"));
String legacyRequest = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/legacy/EmployeeSearchLegacyRequest.java"));
assertFalse(converter.contains("phoneNumber"), converter);
assertTrue(converter.contains("unmappedTargetPolicy = ReportingPolicy.IGNORE"), converter);
assertTrue(legacyRequest.contains("private String employeeId;"), legacyRequest);
String implementation = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/impl/EmployeeSearchUseCaseImpl.java"));
assertTrue(implementation.contains("public EmployeeSearchResponse execute(EmployeeSearchRequest req)"), implementation);
assertTrue(implementation.contains("import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;"), implementation);
assertTrue(implementation.contains("private final AxhubHttpComponent http;"), implementation);
assertTrue(implementation.contains("AxhubHttpDomain.SAMPLE,"), implementation);
assertTrue(implementation.contains("\"/HR_EMPLOYEE_SEARCH\""), implementation);
assertFalse(implementation.contains("executeLegacy(\"HTTP\""), implementation);
} }
} }

View File

@@ -4,11 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
class ToolSchemaResolverTest { class ToolSchemaResolverTest {
@@ -23,14 +25,6 @@ class ToolSchemaResolverTest {
assertTrue(properties(schema).containsKey("differentField")); assertTrue(properties(schema).containsKey("differentField"));
} }
@Test
void resolvesExplicitOutputSchema() throws Exception {
Method method = OutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
Map<String, Object> schema = resolver.resolveOutput(method.getAnnotation(McpTool.class));
assertEquals(false, schema.get("additionalProperties"));
assertTrue(properties(schema).containsKey("resultCode"));
}
@Test @Test
void generatesOutputSchemaFromMarkedResponseDto() throws Exception { void generatesOutputSchemaFromMarkedResponseDto() throws Exception {
Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
@@ -45,10 +39,13 @@ class ToolSchemaResolverTest {
@Test @Test
void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception { void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception {
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
Map<String, Object> schema = resolver.resolveOutput( Map<String, Object> schema = resolver.resolveOutput(
method.getAnnotation(McpTool.class), AutomaticRequest.class); method.getAnnotation(McpTool.class), AutomaticRequest.class);
assertTrue(schema.isEmpty()); assertTrue(schema.isEmpty());
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> properties(Map<String, Object> schema) { private Map<String, Object> properties(Map<String, Object> schema) {
return (Map<String, Object>) schema.get("properties"); return (Map<String, Object>) schema.get("properties");
@@ -60,30 +57,26 @@ class ToolSchemaResolverTest {
} }
static class AutomaticSchemaTool { static class AutomaticSchemaTool {
@McpTool(name = "oth.test.automatic.search") @McpTool(name = "oth_test_automatic_search")
void search(AutomaticRequest request) { void search(AutomaticRequest request) {
} }
} }
static class AutomaticOutputSchemaTool { static class AutomaticOutputSchemaTool {
@McpTool(name = "oth.test.output.search") @McpTool(name = "oth_test_output_search")
SimpleResponse search(AutomaticRequest request) { SimpleResponse search(AutomaticRequest request) {
return null; return null;
} }
} }
@McpOutputSchema
static class SimpleResponse { static class SimpleResponse {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"SUCCESS", "FAILURE"})
private String resultCode; private String resultCode;
private String message; private String message;
} }
static class OutputSchemaTool {
@McpTool(name = "oth.test.explicit.search")
void search(AutomaticRequest request) {
}
}
static class AutomaticRequest { static class AutomaticRequest {
private String differentField; private String differentField;
} }

View File

@@ -18,19 +18,19 @@ class ToolSourceUpdaterTest {
Files.createDirectories(source.getParent()); Files.createDirectories(source.getParent());
Files.writeString(source, """ Files.writeString(source, """
package example; package example;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
interface SampleUseCase { interface SampleUseCase {
@McpTool(name = "oth.cmm.sample.search", description = "old") @McpTool(name = "oth_cmm_sample_search", description = "old")
@ToolHint(register = false, requiresApproval = false) @ToolHint(register = false, requiresApproval = false)
void search(); void search();
} }
"""); """);
ToolSourceUpdater.updateToolSource(temporaryRoot, "oth.cmm.sample.search", "customer", "new", true, true); ToolSourceUpdater.updateToolSource(temporaryRoot, "oth_cmm_sample_search", "customer", "new", true, true);
String updated = Files.readString(source); String updated = Files.readString(source);
assertTrue(updated.contains("@McpTool(name = \"oth.cmm.sample.search\", description = \"new\")")); assertTrue(updated.contains("@McpTool(name = \"oth_cmm_sample_search\", description = \"new\")"));
assertTrue(updated.contains("@ToolHint(register = true, requiresApproval = true")); assertTrue(updated.contains("@ToolHint(register = true, requiresApproval = true"));
assertTrue(updated.contains("categoryKey = \"customer\""), updated); assertTrue(updated.contains("categoryKey = \"customer\""), updated);
} }

View File

@@ -31,35 +31,53 @@ class McpToolNameValidatorTest {
@Test @Test
void rejectsDuplicateMcpToolNamesAcrossToolModules() throws IOException { void rejectsDuplicateMcpToolNamesAcrossToolModules() throws IOException {
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send"); writeToolSource("dap-was-first", "FirstTool.java", "first", "oth_sms_notification_send");
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send"); writeToolSource("dap-was-second", "SecondTool.java", "second", "oth_sms_notification_send");
IllegalStateException exception = assertThrows(IllegalStateException.class, IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot)); () -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains("oth.sms.notification.send")); assertTrue(exception.getMessage().contains("oth_sms_notification_send"));
assertTrue(exception.getMessage().contains("dap-was-first")); assertTrue(exception.getMessage().contains("dap-was-first"));
assertTrue(exception.getMessage().contains("dap-was-second")); assertTrue(exception.getMessage().contains("dap-was-second"));
} }
@Test @Test
void validationRunnerRejectsDuplicateMcpToolNamesBeforePackaging() throws IOException { void validationRunnerRejectsDuplicateMcpToolNamesBeforePackaging() throws IOException {
writeToolSource("dap-was-first", "FirstTool.java", "first", "oth.sms.notification.send"); writeToolSource("dap-was-first", "FirstTool.java", "first", "oth_sms_notification_send");
writeToolSource("dap-was-second", "SecondTool.java", "second", "oth.sms.notification.send"); writeToolSource("dap-was-second", "SecondTool.java", "second", "oth_sms_notification_send");
assertThrows(IllegalStateException.class, assertThrows(IllegalStateException.class,
() -> McpToolNameValidationRunner.validate(temporaryRoot)); () -> McpToolNameValidationRunner.validate(temporaryRoot));
} }
@Test @Test
void rejectsToolNameOutsidePodDomainServiceActionConvention() throws IOException { void rejectsToolNameOutsideConfiguredPattern() throws IOException {
writeToolSource("dap-was-first", "FirstTool.java", "first", "bond_issue"); writeToolSource("dap-was-first", "FirstTool.java", "first", "bond.issue");
IllegalStateException exception = assertThrows(IllegalStateException.class, IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot)); () -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains("Invalid MCP tool name(s)")); assertTrue(exception.getMessage().contains("Invalid MCP tool name(s)"));
assertTrue(exception.getMessage().contains("bond_issue")); assertTrue(exception.getMessage().contains("bond.issue"));
}
@Test
void acceptsLettersDigitsUnderscoresAndDashesWithin128Characters() throws IOException {
String validName = "Tool_Name-" + "a".repeat(118);
writeToolSource("dap-was-first", "FirstTool.java", "first", validName);
assertDoesNotThrow(() -> McpToolNameValidator.assertUnique(temporaryRoot));
}
@Test
void rejectsToolNameLongerThan128Characters() throws IOException {
String invalidName = "a".repeat(129);
writeToolSource("dap-was-first", "FirstTool.java", "first", invalidName);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains(invalidName));
} }
@Test @Test
void acceptsCurrentProjectToolNames() { void acceptsCurrentProjectToolNames() {
@@ -71,11 +89,11 @@ class McpToolNameValidatorTest {
Path root = findProjectRoot(); Path root = findProjectRoot();
assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/CustomerInfoUseCase.java", assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/CustomerInfoUseCase.java",
"oth.cmm.customer.detail", "detail"); "oth_cmm_customer_detail", "detail");
assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BillingProcessUseCase.java", assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BillingProcessUseCase.java",
"oth.cmm.billing.process", "process"); "oth_cmm_billing_process", "process");
assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BondIssueUseCase.java", assertToolName(root, "dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BondIssueUseCase.java",
"oth.cmm.bond.issue", "issue"); "oth_cmm_bond_issue", "issue");
} }
private void assertToolName(Path root, String relativePath, String expectedName, String legacyName) throws IOException { private void assertToolName(Path root, String relativePath, String expectedName, String legacyName) throws IOException {
@@ -90,7 +108,7 @@ class McpToolNameValidatorTest {
Files.writeString(source, """ Files.writeString(source, """
package example; package example;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
class %s { class %s {
@McpTool(name = "%s") @McpTool(name = "%s")

View File

@@ -1,4 +1,4 @@
package io.shinhanlife.dap.mcc.manifest; package io.shinhanlife.dap.lib.manifest;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -6,9 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.lib.manifest.ToolManifestItem;
import io.shinhanlife.dap.lib.manifest.ToolManifestResponse;
import io.shinhanlife.dap.lib.manifest.ToolManifestService;
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -55,7 +52,7 @@ class ToolManifestServiceTest {
void rejectsEntireManifestWhenToolNameDoesNotMatchConfiguredPrefix() { void rejectsEntireManifestWhenToolNameDoesNotMatchConfiguredPrefix() {
McpProperties properties = manifestProperties("insurance-processing", "processing."); McpProperties properties = manifestProperties("insurance-processing", "processing.");
ToolManifestService service = new ToolManifestService( ToolManifestService service = new ToolManifestService(
() -> List.of(tool("notification.sms.send", "1.0.0", 3000)), objectMapper, properties); () -> List.of(tool("notification_sms_send", "1.0.0", 3000)), objectMapper, properties);
IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest); IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest);

View File

@@ -11,12 +11,9 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.McpSyncServer;
import io.shinhanlife.dap.lib.mcp.*; import io.shinhanlife.dap.lib.mcp.*;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
@@ -42,22 +39,22 @@ class McpRequestHeaderFilterTest {
} }
@Test @Test
void forwardsCapturedHeadersToBusinessToolExecution() throws Exception { void forwardsCapturedHeadersToSharedToolExecutionService() throws Exception {
BusinessToolController controller = mock(BusinessToolController.class); McpToolExecutionService service = mock(McpToolExecutionService.class);
doReturn(ResponseEntity.ok(Map.of("result", "ok"))) McpRequestHeaders headers = new McpRequestHeaders(
.when(controller).executeDynamicTool(eq("sampleTool"), eq("gateway-request-id"), eq("trace-001"), "gateway-request-id", "trace-001", "tool-request-001", "encrypted-employee-id");
eq("tool-request-001"), eq("encrypted-employee-id"), eq(Map.of("key", "value"))); doReturn(new ToolExecutionResult(200, Map.of("result", "ok"), Map.of()))
.when(service).execute(eq("sampleTool"), eq(headers), eq(Map.of("key", "value")));
ToolPodMcpToolSynchronizer synchronizer = new ToolPodMcpToolSynchronizer( ToolPodMcpToolSynchronizer synchronizer = new ToolPodMcpToolSynchronizer(
mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), controller, new ObjectMapper()); mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), service, new ObjectMapper());
Method invoke = ToolPodMcpToolSynchronizer.class.getDeclaredMethod( Method invoke = ToolPodMcpToolSynchronizer.class.getDeclaredMethod(
"invoke", String.class, McpRequestHeaders.class, Map.class); "invoke", String.class, McpRequestHeaders.class, Map.class);
invoke.setAccessible(true); invoke.setAccessible(true);
invoke.invoke(synchronizer, "sampleTool", invoke.invoke(synchronizer, "sampleTool",
new McpRequestHeaders("gateway-request-id", "trace-001", "tool-request-001", "encrypted-employee-id"), headers,
Map.of("key", "value")); Map.of("key", "value"));
verify(controller).executeDynamicTool("sampleTool", "gateway-request-id", "trace-001", verify(service).execute("sampleTool", headers, Map.of("key", "value"));
"tool-request-001", "encrypted-employee-id", Map.of("key", "value"));
} }
} }

View File

@@ -1,13 +1,10 @@
plugins { plugins {
// OTH Tool Pod를 독립 실행 가능한 Spring Boot JAR로 생성합니다.
id 'org.springframework.boot' id 'org.springframework.boot'
} }
dependencies { dependencies {
// MCP Server, Tool 공통 처리, MCI/EAI 연동 기반은 dap-was-lib에서 상속합니다.
implementation project(':dap-was-lib') implementation project(':dap-was-lib')
implementation 'org.apache.poi:poi-ooxml:5.3.0' implementation 'org.apache.poi:poi-ooxml:5.3.0'
} }
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -5,7 +5,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
/** /**
* @package io.shinhanlife.dap.mcc.dto * @package io.shinhanlife.dap.mcc.dto
* @className BalanceRequest * @className BalanceRequest

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import lombok.Builder; import lombok.Builder;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -1,12 +1,12 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface BalanceUseCase { public interface BalanceUseCase {
@McpTool(name = "oth.cmm.balance.inquiry", title = "잔고 조회 툴", description = "고객의 계좌 잔액을 조회합니다.") @McpTool(name = "oth_cmm_balance_inquiry", title = "잔고 조회 툴", description = "고객의 계좌 잔액을 조회합니다.")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "ACC_001") @ToolHint(register = false, categoryKey = "cmm", mappingId = "ACC_001")
Object execute(BalanceRequest req); Object execute(BalanceRequest req);
} }

View File

@@ -1,13 +1,13 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface BillingProcessUseCase { public interface BillingProcessUseCase {
Object getStatus(BillingStatusRequest req); Object getStatus(BillingStatusRequest req);
@McpTool(name = "oth.cmm.billing.process", title = "청구 프로세스 툴", description = "청구 처리") @McpTool(name = "oth_cmm_billing_process", title = "청구 프로세스 툴", description = "청구 처리")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "BILL_002") @ToolHint(register = false, categoryKey = "cmm", mappingId = "BILL_002")
Object processBilling(BillingProcessRequest data); Object processBilling(BillingProcessRequest data);
} }

View File

@@ -1,14 +1,14 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface BondIssueUseCase { public interface BondIssueUseCase {
Object check(BondCheckRequest req); Object check(BondCheckRequest req);
@McpTool(name = "oth.cmm.bond.issue", title = "채권 발행 툴", description = "증권 발행 테스트1") @McpTool(name = "oth_cmm_bond_issue", title = "채권 발행 툴", description = "증권 발행 테스트1")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "BOND_002") @ToolHint(register = false, categoryKey = "cmm", mappingId = "BOND_002")
Object issue(BondIssueRequest data); Object issue(BondIssueRequest data);
} }

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest; import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest;
@@ -8,9 +8,9 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchResponse;
public interface ClaimSearchSchemaSampleUseCase { public interface ClaimSearchSchemaSampleUseCase {
@McpTool(name = "oth.cmm.claim.search", title = "청구 조회 스키마 샘플", description = "Claim search Tool sample using input and output JSON Schema resources.", annotations = @McpTool.McpAnnotations(readOnlyHint = true, idempotentHint = true)) @McpTool(name = "oth_cmm_claim_search", title = "청구 조회 스키마 샘플", description = "Claim search Tool sample using input and output JSON Schema resources.", annotations = @McpTool.McpAnnotations(readOnlyHint = true, idempotentHint = true))
@ToolHint(register = false, categoryKey = "cmm", @ToolHint(register = false, categoryKey = "cmm",
inputSchemaResource = "classpath:mcp/schema/claim-search-resource-input-schema.json", inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
outputSchemaResource = "classpath:mcp/schema/claim-search-resource-output-schema.json") outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json")
ClaimSearchResponse search(ClaimSearchRequest request); ClaimSearchResponse search(ClaimSearchRequest request);
} }

View File

@@ -1,18 +1,18 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface CommonUtilityUseCase { public interface CommonUtilityUseCase {
Object registerVacation(VacationRegisterRequest req); Object registerVacation(VacationRegisterRequest req);
@McpTool(name = "oth.cmm.leave.count", title = "공통 유틸리티 툴", description = "연차 갯수 조회") @McpTool(name = "oth_cmm_leave_count", title = "공통 유틸리티 툴", description = "연차 갯수 조회")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "HR_VAC_02") @ToolHint(register = false, categoryKey = "cmm", mappingId = "HR_VAC_02")
Object getLeaveCount(LeaveCountRequest data); Object getLeaveCount(LeaveCountRequest data);
@McpTool(name = "oth.cmm.secret.execute", title = "공통 유틸리티 툴", description = "비공개 툴 테스트") @McpTool(name = "oth_cmm_secret_execute", title = "공통 유틸리티 툴", description = "비공개 툴 테스트")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "SECRET_001") @ToolHint(register = false, categoryKey = "cmm", mappingId = "SECRET_001")
Object secretTool(LeaveCountRequest data); Object secretTool(LeaveCountRequest data);
} }

View File

@@ -1,14 +1,14 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface ContractInquiryUseCase { public interface ContractInquiryUseCase {
Object getStatus(ContractStatusRequest req); Object getStatus(ContractStatusRequest req);
@McpTool(name = "oth.cmm.contract.detail", title = "계약 상세조회 툴", description = "계약상세 조회") @McpTool(name = "oth_cmm_contract_detail", title = "계약 상세조회 툴", description = "계약상세 조회")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "CNTR_002") @ToolHint(register = false, categoryKey = "cmm", mappingId = "CNTR_002")
Object getDetail(ContractDetailRequest data); Object getDetail(ContractDetailRequest data);
} }

View File

@@ -1,14 +1,14 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
public interface CustomerInfoUseCase { public interface CustomerInfoUseCase {
Object getGrade(CustomerGradeRequest req); Object getGrade(CustomerGradeRequest req);
@McpTool(name = "oth.cmm.customer.detail", title = "고객 상세조회 툴", description = "고객상세 정보 조회") @McpTool(name = "oth_cmm_customer_detail", title = "고객 상세조회 툴", description = "고객상세 정보 조회")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "CRM_002") @ToolHint(register = false, categoryKey = "cmm", mappingId = "CRM_002")
Object getDetail(CustomerDetailRequest data); Object getDetail(CustomerDetailRequest data);
} }

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest; import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
@@ -20,7 +20,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
* </pre> * </pre>
*/ */
public interface MetaCommonCodeUseCase { public interface MetaCommonCodeUseCase {
@McpTool(name = "oth.cmm.common-code.lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true)) @McpTool(name = "oth_cmm_commonCode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
@ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001") @ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
Object execute(MetaCommonCodeRequest req); Object execute(MetaCommonCodeRequest req);
} }

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest; import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
@@ -20,7 +20,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
* </pre> * </pre>
*/ */
public interface MetaTableUseCase { public interface MetaTableUseCase {
@McpTool(name = "oth.cmm.meta.table", title = "메타 테이블 조회 툴", description = "메타 테이블 정보 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true)) @McpTool(name = "oth_cmm_meta_table", title = "메타 테이블 조회 툴", description = "메타 테이블 정보 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
@ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001") @ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
Object execute(MetaTableRequest req); Object execute(MetaTableRequest req);
} }

View File

@@ -1,14 +1,14 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase; package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.*; import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
import java.util.Map; import java.util.Map;
public interface TemplateUtilityUseCase { public interface TemplateUtilityUseCase {
@McpTool(name = "oth.cmm.template.url", title = "템플릿 유틸리티 툴", description = "요청한 템플릿(엑셀, 워드 등) 파일(양식)을 다운로드 받을 수 있는 시스템 URL을 반환합니다. AI는 이 URL을 사용자에게 마크다운 링크 형태로 제공해야 합니다.") @McpTool(name = "oth_cmm_template_url", title = "템플릿 유틸리티 툴", description = "요청한 템플릿(엑셀, 워드 등) 파일(양식)을 다운로드 받을 수 있는 시스템 URL을 반환합니다. AI는 이 URL을 사용자에게 마크다운 링크 형태로 제공해야 합니다.")
@ToolHint(categoryKey = "cmm") @ToolHint(categoryKey = "cmm")
Map<String, Object> getTemplateFileUrl(TemplateDownloadRequest req); Map<String, Object> getTemplateFileUrl(TemplateDownloadRequest req);
} }

View File

@@ -1,11 +1,11 @@
package io.shinhanlife.dap.mcc.biz.oth.usecase; package io.shinhanlife.dap.mcc.biz.oth.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.oth.dto.*; import io.shinhanlife.dap.mcc.biz.oth.dto.*;
public interface Onnba3011UseCase { public interface Onnba3011UseCase {
@McpTool(name = "oth.oth.onnba3011.call", description = "Onnba3011 호출 툴") @McpTool(name = "oth_oth_onnba3011_call", description = "Onnba3011 호출 툴")
@ToolHint(categoryKey = "oth") @ToolHint(categoryKey = "oth", register = false)
Object execute(Onnba3011Request req); Object execute(Onnba3011Request req);
} }

View File

@@ -5,7 +5,7 @@ package io.shinhanlife.dap.mcc.biz.smp.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -5,7 +5,7 @@ package io.shinhanlife.dap.mcc.biz.smp.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -0,0 +1,9 @@
package io.shinhanlife.dap.mcc.biz.smp.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/** Tool response for the HTTP integration sample. */
public record SampleHttpStatusResponse(
@Schema(description = "External API processing status", example = "OK") String status,
@Schema(description = "External API response message", example = "Sample API is available") String message) {
}

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.smp.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcc.biz.smp.dto;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription; import com.fasterxml.jackson.annotation.JsonPropertyDescription;

View File

@@ -1,13 +1,13 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase; package io.shinhanlife.dap.mcc.biz.smp.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteRequest; import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteRequest;
import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteResponse; import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteResponse;
public interface DailyQuoteToolUseCase { public interface DailyQuoteToolUseCase {
@McpTool(name = "oth.smp.quote.daily", title = "오늘의 명언 툴", description = "무작위로 영감을 주는 명언을 하나 가져옵니다.") @McpTool(name = "oth_smp_quote_daily", title = "오늘의 명언 툴", description = "무작위로 영감을 주는 명언을 하나 가져옵니다.")
@ToolHint(register = false, categoryKey = "smp", mappingId = "QUOTE_001") @ToolHint(register = false, categoryKey = "smp", mappingId = "QUOTE_001")
DailyQuoteResponse execute(DailyQuoteRequest req); DailyQuoteResponse execute(DailyQuoteRequest req);
} }

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase; package io.shinhanlife.dap.mcc.biz.smp.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest; import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse; import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
@@ -9,7 +9,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse; import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
public interface ExchangeRateToolUseCase { public interface ExchangeRateToolUseCase {
@McpTool(name = "oth.smp.exchange-rate.inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)") @McpTool(name = "oth_smp_exchangeRate_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)")
@ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001") @ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001")
ExchangeRateResponse execute(ExchangeRateRequest req); ExchangeRateResponse execute(ExchangeRateRequest req);
} }

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
import org.springaicommunity.mcp.annotation.McpTool;
/** Sample Tool that demonstrates a configured HTTP API integration. */
public interface SampleHttpStatusUseCase {
@McpTool(name = "oth_smp_sample_status",
title = "Sample external HTTP API status",
description = "Calls the configured sample HTTP API and returns its status.")
@ToolHint(register = false, categoryKey = "smp", mappingId = "HTTP_SAMPLE_001")
SampleHttpStatusResponse execute();
}

View File

@@ -1,12 +1,12 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase; package io.shinhanlife.dap.mcc.biz.smp.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.TeamMemberRequest; import io.shinhanlife.dap.mcc.biz.smp.dto.TeamMemberRequest;
public interface TeamMemberUseCase { public interface TeamMemberUseCase {
@McpTool(name = "oth.smp.team.list", title = "신한라이프 MCP, TOOL 파트 구성원 조회", description = "신한라이프 MCP, TOOL 파트 구성원을 조회합니다.", annotations = @McpTool.McpAnnotations(openWorldHint = true)) @McpTool(name = "oth_smp_team_list", title = "신한라이프 MCP, TOOL 파트 구성원 조회", description = "신한라이프 MCP, TOOL 파트 구성원을 조회합니다.", annotations = @McpTool.McpAnnotations(openWorldHint = true))
@ToolHint(register = false, requiresApproval = false, categoryKey = "smp", mappingId = "DIRECT0001") @ToolHint(register = false, requiresApproval = false, categoryKey = "smp", mappingId = "DIRECT0001")
Object execute(TeamMemberRequest req); Object execute(TeamMemberRequest req);
} }

View File

@@ -1,12 +1,12 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase; package io.shinhanlife.dap.mcc.biz.smp.usecase;
import org.springframework.ai.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.*; import io.shinhanlife.dap.mcc.biz.smp.dto.*;
public interface WeatherToolUseCase { public interface WeatherToolUseCase {
@McpTool(name = "oth.smp.weather.inquiry", title = "날씨 조회 툴", description = "특정 도시의 현재 날씨, 온도, 풍속 정보를 조회합니다.") @McpTool(name = "oth_smp_weather_inquiry", title = "날씨 조회 툴", description = "특정 도시의 현재 날씨, 온도, 풍속 정보를 조회합니다.")
@ToolHint(register = false, categoryKey = "smp", mappingId = "WEATHER_001") @ToolHint(register = false, categoryKey = "smp", mappingId = "WEATHER_001")
WeatherResponse execute(WeatherRequest req); WeatherResponse execute(WeatherRequest req);
} }

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase.impl;
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
import io.shinhanlife.dap.mcc.biz.smp.usecase.SampleHttpStatusUseCase;
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiClient;
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SampleHttpStatusUseCaseImpl implements SampleHttpStatusUseCase {
private final SampleHttpApiClient sampleHttpApiClient;
@Override
public SampleHttpStatusResponse execute() {
SampleHttpApiResponse response = sampleHttpApiClient.getStatus();
return new SampleHttpStatusResponse(response.status(), response.message());
}
}

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.sol.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

View File

@@ -4,7 +4,7 @@ package io.shinhanlife.dap.mcc.biz.sol.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.ai.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data; import lombok.Data;

Some files were not shown because too many files have changed in this diff Show More