refactor: apply Option B, remove heartbeat and register tools once on startup
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 44s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 44s
This commit is contained in:
34
README.md
34
README.md
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 카테고리도 기본적으로 항상 열어두도록 추가
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
Reference in New Issue
Block a user