refactor: apply Option B, remove heartbeat and register tools once on startup
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 44s

This commit is contained in:
jade
2026-08-13 17:34:32 +09:00
parent 34e678ad63
commit 8f1c64ac58
177 changed files with 718 additions and 170 deletions

View File

@@ -1,5 +1,27 @@
# AX HUB MCP Tool Platform
## Current Tool Pod modules
| Pod role | Gradle module | Container service | Internal endpoint | Local Docker endpoint |
|---|---|---|---|---|
| Customer/common integration | `dap-was-cus` | `was-cus` | `http://was-cus:8084/mcp` | `http://localhost:8284/mcp` |
| Sales/notification integration | `dap-was-sal` | `was-sal` | `http://was-sal:8082/mcp` | `http://localhost:8282/mcp` |
| Process integration (new) | `dap-was-pro` | `was-pro` | `http://was-pro:8085/mcp` | `http://localhost:8085/mcp` |
| System integration (new) | `dap-was-sys` | `was-sys` | `http://was-sys:8086/mcp` | `http://localhost:8086/mcp` |
- The former `dap-was-oth` module is now `dap-was-cus`; the former `dap-was-sms` module is now `dap-was-sal`.
- This is a deployment Pod rename only. Existing tool category and function names such as `oth_*` and `sms_*` remain valid so already registered MCP clients are not broken.
- `dap-was-pro` and `dap-was-sys` are empty, independently deployable Tool Pods ready for new business tools.
### Run the Tool Pods locally
```powershell
.\gradlew.bat :dap-was-cus:bootRun
.\gradlew.bat :dap-was-sal:bootRun
.\gradlew.bat :dap-was-pro:bootRun
.\gradlew.bat :dap-was-sys:bootRun
```
AX HUB에서 AI Agent가 업무 Tool을 검색하고 호출할 수 있도록 Gateway와 독립 Tool Pod를 제공하는 멀티 모듈 Spring Boot 프로젝트입니다.
## 1. 현재 구성
@@ -61,8 +83,10 @@ $env:OPENROUTER_API_KEY = '<발급받은-키>'
```powershell
.\gradlew.bat :dap-gateway:bootRun
.\gradlew.bat :dap-was-sms:bootRun
.\gradlew.bat :dap-was-oth:bootRun
.\gradlew.bat :dap-was-sal:bootRun
.\gradlew.bat :dap-was-cus:bootRun
.\gradlew.bat :dap-was-pro:bootRun
.\gradlew.bat :dap-was-sys:bootRun
```
### 4.2 Docker Compose 실행
@@ -227,8 +251,10 @@ https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
```powershell
.\gradlew.bat :dap-was-lib:test
.\gradlew.bat :dap-was-oth:compileJava
.\gradlew.bat :dap-was-sms:compileJava
.\gradlew.bat :dap-was-cus:compileJava
.\gradlew.bat :dap-was-sal:compileJava
.\gradlew.bat :dap-was-pro:compileJava
.\gradlew.bat :dap-was-sys:compileJava
.\gradlew.bat validateMcpToolNames
```

View File

@@ -38,8 +38,14 @@ subprojects {
// Spring Boot 3.5.11과 호환되는 Spring/Jackson/Tomcat 등의 버전을 관리합니다.
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
// Spring AI, MCP, OpenAI 연동 라이브러리의 호환 버전을 1.1.8로 고정합니다.
// Boot 3.5.11과 호환되는 Spring AI 계층은 1.1.8로 유지합니다.
mavenBom 'org.springframework.ai:spring-ai-bom:1.1.8'
// MCP Java SDK만 2.0.0으로 올립니다. Spring AI 2.x 전체 BOM은 Boot 4 기반이므로 사용하지 않습니다.
mavenBom 'io.modelcontextprotocol.sdk:mcp-bom:2.0.0'
// MCP SDK 2.0의 Jackson 2 전송 모듈이 요구하는 호환 버전입니다.
mavenBom 'com.fasterxml.jackson:jackson-bom:2.20.1'
}
}

View File

@@ -18,8 +18,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
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'
// Gateway MCP Server/Client는 공통 모듈이 제공하는 MCP Java SDK 2.0.0을 사용합니다.
// ChatClient를 통해 OpenAI/OpenRouter 호환 모델을 호출합니다.
implementation 'org.springframework.ai:spring-ai-starter-model-openai'

View File

@@ -250,13 +250,5 @@ public class McpRouterController {
return ResponseEntity.ok("Deregistered");
}
@PostMapping("/registry/heartbeat")
public ResponseEntity<String> heartbeat(@RequestBody String uid) {
boolean success = registryService.refreshHeartbeat(uid);
if (success) {
return ResponseEntity.ok("Heartbeat updated");
} else {
return ResponseEntity.status(404).body("Tool not found");
}
}
}

View File

@@ -58,7 +58,7 @@ public class ScaffoldingController {
@PostMapping("/pod")
public String scaffoldPod(@RequestBody Map<String, String> req) {
try {
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
String moduleName = req.getOrDefault("moduleName", "dap-was-cus");
if (!moduleName.startsWith("dap-was-")) moduleName = "dap-was-" + moduleName;
String port = req.getOrDefault("port", "8085");
String shortName = moduleName.replace("dap-was-", "").replace("-", "");
@@ -83,7 +83,7 @@ public class ScaffoldingController {
String description = req.get("description");
String group = req.getOrDefault("categoryKey", req.getOrDefault("group", "COMMON"));
String routingType = req.getOrDefault("routingType", "HTTP");
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
String moduleName = req.getOrDefault("moduleName", "dap-was-cus");
String author = req.get("author");
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
String date = req.get("date");
@@ -124,7 +124,7 @@ public class ScaffoldingController {
throw new IllegalArgumentException("UseCase name must be PascalCase.");
}
String moduleName = request.moduleName() == null || request.moduleName().isBlank()
? "dap-was-oth" : request.moduleName().trim();
? "dap-was-cus" : request.moduleName().trim();
String author = request.author() == null || request.author().isBlank()
? System.getProperty("user.name") : request.author().trim();
String date = request.date() == null || request.date().isBlank()
@@ -248,12 +248,12 @@ public class ScaffoldingController {
File[] files = dir.listFiles(f -> f.isDirectory() && f.getName().startsWith("dap-was-") && !f.getName().equals("dap-was-lib"));
if (files == null || files.length == 0) {
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
return List.of("dap-was-cus", "dap-was-sal", "dap-was-pro", "dap-was-sys");
}
return Arrays.stream(files).map(File::getName).sorted().collect(Collectors.toList());
} catch (Exception e) {
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
return List.of("dap-was-cus", "dap-was-sal", "dap-was-pro", "dap-was-sys");
}
}

View File

@@ -30,26 +30,10 @@ public class InMemoryRegistryService {
* 툴 등록 및 갱신
*/
public void saveTool(ToolMetadata meta) {
meta.setLastHeartbeat(System.currentTimeMillis());
toolCache.put(meta.getUid(), meta);
log.info(" [InMemoryRegistry] 툴 등록 완료: {}", meta.getUid());
}
/**
* 하트비트 갱신 (TTL 초기화)
*/
public boolean refreshHeartbeat(String uid) {
ToolMetadata meta = toolCache.get(uid);
if (meta != null) {
meta.setLastHeartbeat(System.currentTimeMillis());
log.debug(" [InMemoryRegistry] 하트비트 갱신: {}", uid);
return true;
} else {
log.warn(" [InMemoryRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid);
return false;
}
}
public List<String> getAvailablePods(String uid) {
ToolMetadata tool = getTool(uid);
if (tool != null && tool.getPodUrl() != null) {
@@ -91,21 +75,4 @@ public class InMemoryRegistryService {
toolCache.remove(uid);
log.info(" [InMemoryRegistry] 툴 삭제 완료: {}", uid);
}
/**
* 만료된 툴을 스케줄러로 삭제합니다. (10초마다 실행)
*/
@Scheduled(fixedRate = 10000)
public void evictExpiredTools() {
long now = System.currentTimeMillis();
List<String> expiredKeys = toolCache.entrySet().stream()
.filter(entry -> (now - entry.getValue().getLastHeartbeat()) > DEFAULT_TTL_MILLIS)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
for (String key : expiredKeys) {
toolCache.remove(key);
log.info(" [InMemoryRegistry] TTL 초과로 툴 자동 삭제: {}", key);
}
}
}

View File

@@ -51,9 +51,15 @@ public class DynamicMcpServerManager {
getOrCreateServer("common");
getOrCreateServer("hr");
getOrCreateServer("payment");
// Tool category names are not Pod names. Keep existing external category
// endpoints available while the SMS/OTH Pods transition to SAL/CUS.
getOrCreateServer("sms");
getOrCreateServer("email");
getOrCreateServer("oth");
getOrCreateServer("sal");
getOrCreateServer("email");
getOrCreateServer("cus");
getOrCreateServer("pro");
getOrCreateServer("sys");
getOrCreateServer("sample");
getOrCreateServer("smp"); // smp 카테고리도 기본적으로 항상 열어두도록 추가
}

View File

@@ -10,10 +10,15 @@
mcp:
gateway:
fallback:
default-url: http://was-oth:8084
default-url: http://was-cus:8084
routes:
sms: http://was-sms:8082
hr: http://was-oth:8084
cus: http://was-cus:8084
sal: http://was-sal:8082
sms: http://was-sal:8082
oth: http://was-cus:8084
hr: http://was-cus:8084
pro: http://was-pro:8085
sys: http://was-sys:8086
# --- 신한라이프 EAI/MCI 연계 IP 정보 (개발 환경) ---
shinhan:

View File

@@ -37,10 +37,15 @@ mcp:
default-url: http://localhost:8084
routes:
cmm_memo_retriever: http://localhost:8082
cus: http://localhost:8084
sal: http://localhost:8082
sms: http://localhost:8082
oth: http://localhost:8084
email: http://localhost:8083
payment: http://localhost:8085
hr: http://localhost:8084
pro: http://localhost:8085
sys: http://localhost:8086
tool:
host: localhost

View File

@@ -27,10 +27,16 @@ server:
mcp:
gateway:
fallback:
default-url: http://was-oth:8084
default-url: http://was-cus:8084
routes:
sms: http://was-sms:8082
hr: http://was-oth:8084
cus: http://was-cus:8084
sal: http://was-sal:8082
# Existing SMS category callers continue to resolve to the SAL Pod.
sms: http://was-sal:8082
oth: http://was-cus:8084
hr: http://was-cus:8084
pro: http://was-pro:8085
sys: http://was-sys:8086
agent-claims-required: false
trusted-claims-required: false
write-approval-required: false

View File

@@ -981,9 +981,11 @@
<div class="col-md-6">
<label class="form-label">Target Module</label>
<select class="form-select" name="moduleName" id="targetModuleSelect" required>
<option value="dap-was-oth">dap-was-oth</option>
<option value="dap-was-cus">dap-was-cus</option>
<option value="dap-was-hr">dap-was-hr</option>
<option value="dap-was-sms">dap-was-sms</option>
<option value="dap-was-sal">dap-was-sal</option>
<option value="dap-was-pro">dap-was-pro</option>
<option value="dap-was-sys">dap-was-sys</option>
</select>
<div class="input-hint">Destination Pod directory.</div>
</div>
@@ -1478,7 +1480,7 @@
.then(modules => {
const select = document.getElementById('targetModuleSelect');
if (modules && modules.length > 0) {
select.innerHTML = modules.map(m => `<option value="${m}" ${m === 'dap-was-oth' ? 'selected' : ''}>${m}</option>`).join('');
select.innerHTML = modules.map(m => `<option value="${m}" ${m === 'dap-was-cus' ? 'selected' : ''}>${m}</option>`).join('');
}
})
.catch(err => console.error('Failed to load modules:', err));

View File

@@ -2,7 +2,7 @@ FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
COPY dap-was-oth/build/libs/*-SNAPSHOT.jar app.jar
COPY dap-was-cus/build/libs/*-SNAPSHOT.jar app.jar
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,42 @@
package io.shinhanlife.dap.mcc.biz.sol.usecase;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailRequest;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.usecase
* @className SolReqDetailUseCase
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public interface SolReqDetailUseCase {
@McpTool(name = "sol_request_detail", title = "SolReqDetail 툴", description = "SOL 의뢰서 상세 조회", annotations = @McpTool.McpAnnotations(openWorldHint = true, readOnlyHint = true))
@GrowToolHint(
requiresApproval = false,
categoryKey = "sol",
mappingId = "SOLG00000002",
displayDescription = "SOL 의뢰서 한 건의 상세 정보를 조회합니다.",
functionDescription = "SOL 의뢰서 식별자를 기준으로 의뢰서 상세 내용을 조회한다.",
whenToUse = "사용자가 특정 SOL 의뢰서의 상세 내용과 진행 정보를 확인하려는 경우 사용한다.",
whenNotToUse = "의뢰서 목록만 찾거나 의뢰서를 변경 또는 처리하려는 경우에는 사용하지 않는다.",
ioLimits = "정확한 의뢰서 ID가 필요하며 등록된 상세 정보만 반환한다.",
exampleQueries = {"이 SOL 의뢰서 상세를 보여줘", "의뢰서 ID로 처리 내용을 확인해줘", "선택한 의뢰서의 상세 정보를 알려줘"},
destructive = false,
idempotent = true,
tags = {"SOL", "의뢰서"},
requiredEnvKeys = {},
ownerOrg = "MCP_TOOL"
)
Object getSolRequestDetail(SolReqDetailRequest req);
}

View File

@@ -1,9 +1,9 @@
package io.shinhanlife.dap.mcc.oth;
package io.shinhanlife.dap.mcc.cus;
/**
* @package io.shinhanlife.dap.mcc.oth
* @className DapWasOthApplication
* @package io.shinhanlife.dap.mcc.cus
* @className DapWasCusApplication
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
@@ -26,8 +26,8 @@ import io.shinhanlife.dap.lib.mcp.ToolMcpServerConfiguration;
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dap.mcc", "io.shinhanlife.dap.lib"})
@EnableCaching
@Import(ToolMcpServerConfiguration.class)
public class DapWasOthApplication {
public class DapWasCusApplication {
public static void main(String[] args) {
SpringApplication.run(DapWasOthApplication.class, args);
SpringApplication.run(DapWasCusApplication.class, args);
}
}

View File

@@ -2,7 +2,7 @@ server:
port: 8084
spring:
application:
name: dap-was-oth
name: dap-was-cus
profiles:
active: local
logging:
@@ -11,8 +11,8 @@ logging:
mcp:
namespace: ""
manifest:
bundle-id: was-oth
# Set the AA-assigned prefix before MCP pull activation (for example: oth.).
bundle-id: was-cus
# Set the AA-assigned prefix before MCP pull activation (for example: cus.).
name-prefix: ""
security:
tenant-domains:

View File

@@ -16,10 +16,10 @@
<property name="HOSTNAME" value="${HOSTNAME}" />
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/swlog/dap-was-oth/A01/${HOSTNAME}_A01.log</file> <!-- 현재 로그가 쌓이는 파일 -->
<file>/swlog/dap-was-cus/A01/${HOSTNAME}_A01.log</file> <!-- 현재 로그가 쌓이는 파일 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 매일 자정에 날짜를 붙여서 지난 로그를 분리 저장 -->
<fileNamePattern>/swlog/dap-was-oth/A01/${HOSTNAME}_A01_%d{yyyyMMdd}.%i.log</fileNamePattern>
<fileNamePattern>/swlog/dap-was-cus/A01/${HOSTNAME}_A01_%d{yyyyMMdd}.%i.log</fileNamePattern>
<!-- 개별 로그 파일 크기를 100MB로 제한하고, 전체 보관 일수는 30일, 총 로그 누적 용량은 1GB로 제한 -->
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>

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