23 Commits

Author SHA1 Message Date
jade
b53dfe3601 Enhance ToolScaffolder with Pattern and Examples validation, support AI prompt generation for Pattern
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m22s
2026-08-14 10:29:27 +09:00
jade
b8d6fa28df fix(scaffold): 기존 UseCase에 툴 추가 시 의존성 주입(RequiredArgsConstructor) 누락 및 필드 위치 오류 수정
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m6s
2026-08-14 09:58:50 +09:00
jade
975411598d fix(scaffold): 기존 UseCase에 단일 Tool 추가 시 새 파일(UseCase)이 생성되는 버그 수정
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m10s
2026-08-14 09:51:08 +09:00
jade
09cc22e4a4 fix(scaffold): Domain Category 덮어쓰기 방지 및 하위 필드(itemFields) 편집 팝업 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m20s
2026-08-14 09:34:23 +09:00
jade
954d8879b7 feat(scaffold): Domain Category 콤보박스 및 카테고리 동적 로드 API 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 7m11s
2026-08-14 09:24:39 +09:00
jade
8a7697447f feat: add retry loop for tool registration at startup
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m18s
2026-08-14 00:10:30 +09:00
jade
dfaeae7e01 feat: add additional iam and pct tools
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m31s
2026-08-13 23:54:26 +09:00
jade
58960af817 feat: add iam and pct mock tools
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m30s
2026-08-13 23:47:05 +09:00
jade
18c2301cdf docs: design executable sys mock tools 2026-08-13 23:02:35 +09:00
jade
4cddb4bfe9 docs: add sys mock tools design 2026-08-13 22:48:03 +09:00
jade
a8df07d085 fix: update services in deploy.yml to match docker-compose.yml
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m40s
2026-08-13 22:43:04 +09:00
jade
376edbbcfb fix: remove internal API fields from _meta to match YAML strictly
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 27s
2026-08-13 18:22:43 +09:00
jade
33e1261b38 refactor: restructure tools/list response to strictly follow MCP schema standard (PDF page 62)
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 26s
2026-08-13 18:15:10 +09:00
jade
ad4758d0ba feat: expose _meta in tool metadata and map parametersSchema to inputSchema
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 27s
2026-08-13 18:04:57 +09:00
jade
93257b1bc6 feat: add module filter to tester UI and moduleName to ToolMetadata
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 26s
2026-08-13 17:56:37 +09:00
jade
04497a608c feat: change default gateway registration to true in scaffold UI
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 30s
2026-08-13 17:51:02 +09:00
jade
8f1c64ac58 refactor: apply Option B, remove heartbeat and register tools once on startup
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 44s
2026-08-13 17:34:32 +09:00
jade
34e678ad63 fix: change default of register in @GrowToolHint to true so tools are returned
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 4m33s
2026-08-13 17:15:01 +09:00
jade
46df842732 refactor: remove unnecessary register=false from @GrowToolHint
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 4m33s
2026-08-13 16:54:08 +09:00
jade
f1ca0d6df1 refactor: remove redis and switch to in-memory, rollback incomplete Spring AI 2.0 upgrade
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 4m31s
2026-08-13 16:38:42 +09:00
juheelee
1cb1d90a7e feat: tool report 카테고리 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 4m50s
2026-08-13 16:32:08 +09:00
jade
019cbe4199 Refactor: rename ToolHint to GrowToolHint and update annotations
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m2s
2026-08-13 16:22:15 +09:00
juheelee
206095a7a8 feat: tool report 카테고리 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m29s
2026-08-13 16:13:10 +09:00
355 changed files with 5832 additions and 921 deletions

View File

@@ -59,7 +59,7 @@ jobs:
# 4. 마운트된 /app 디렉토리로 이동하여 호스트의 도커 컴포즈 제어!
cd /app
docker system prune -f
ACTIVE_PROFILE=dev docker compose up -d --build --pull always --remove-orphans gateway redis mci-mock dozzle was-sms was-oth
ACTIVE_PROFILE=dev docker compose up -d --build --pull always --remove-orphans gateway tool-report mci-mock dozzle was-sal was-cus was-pro was-sys
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 및 빌드 캐시 자동 소거 청소!
docker image prune -a -f

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

@@ -13,15 +13,12 @@ dependencies {
// 등록된 Excel 양식을 보존하면서 Tool 문서를 생성합니다.
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Tool Registry 및 분산 캐시 연동에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Tool/Registry 관련 DB 조회와 MyBatis Mapper 실행에 사용합니다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
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

@@ -37,8 +37,8 @@ public class McpGatewayProperties {
private Boolean agentClaimsRequired;
private Boolean trustedClaimsRequired;
private Boolean writeApprovalRequired;
private Boolean redisTraceEnabled;
private Long redisTraceTtlSeconds;
private Boolean traceEnabled;
private Long traceRetentionSize;
private Long toolTimeoutMillis;
private Integer retryMaxAttempts;
private Long retryInitialBackoffMillis;
@@ -70,8 +70,8 @@ public class McpGatewayProperties {
public void setAgentClaimsRequired(Boolean agentClaimsRequired) { this.agentClaimsRequired = agentClaimsRequired; }
public void setTrustedClaimsRequired(Boolean trustedClaimsRequired) { this.trustedClaimsRequired = trustedClaimsRequired; }
public void setWriteApprovalRequired(Boolean writeApprovalRequired) { this.writeApprovalRequired = writeApprovalRequired; }
public void setRedisTraceEnabled(Boolean redisTraceEnabled) { this.redisTraceEnabled = redisTraceEnabled; }
public void setRedisTraceTtlSeconds(Long redisTraceTtlSeconds) { this.redisTraceTtlSeconds = redisTraceTtlSeconds; }
public void setTraceEnabled(Boolean traceEnabled) { this.traceEnabled = traceEnabled; }
public void setTraceRetentionSize(Long traceRetentionSize) { this.traceRetentionSize = traceRetentionSize; }
public void setToolTimeoutMillis(Long toolTimeoutMillis) { this.toolTimeoutMillis = toolTimeoutMillis; }
public void setRetryMaxAttempts(Integer retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; }
public void setRetryInitialBackoffMillis(Long retryInitialBackoffMillis) { this.retryInitialBackoffMillis = retryInitialBackoffMillis; }
@@ -103,8 +103,8 @@ public class McpGatewayProperties {
public boolean agentClaimsRequired() { return agentClaimsRequired == null || agentClaimsRequired; }
public boolean trustedClaimsRequired() { return trustedClaimsRequired == null || trustedClaimsRequired; }
public boolean writeApprovalRequired() { return writeApprovalRequired == null || writeApprovalRequired; }
public boolean redisTraceEnabled() { return redisTraceEnabled == null || redisTraceEnabled; }
public long redisTraceTtlSeconds() { return redisTraceTtlSeconds == null || redisTraceTtlSeconds < 1 ? 3_600 : redisTraceTtlSeconds; }
public boolean traceEnabled() { return traceEnabled == null || traceEnabled; }
public long traceRetentionSize() { return traceRetentionSize == null || traceRetentionSize < 1 ? 1000 : traceRetentionSize; }
public long toolTimeoutMillis() { return toolTimeoutMillis == null || toolTimeoutMillis < 1 ? 5_000 : toolTimeoutMillis; }
public int retryMaxAttempts() { return retryMaxAttempts == null || retryMaxAttempts < 1 ? 3 : retryMaxAttempts; }
public long retryInitialBackoffMillis() { return retryInitialBackoffMillis == null || retryInitialBackoffMillis < 1 ? 1_000 : retryInitialBackoffMillis; }

View File

@@ -1,46 +0,0 @@
package io.shinhanlife.dap.mcg.config;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @package io.shinhanlife.dap.mcg.config
* @className RedisConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, ToolMetadata> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, ToolMetadata> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 1. Key는 무조건 String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음)
Jackson2JsonRedisSerializer<ToolMetadata> serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class);
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -17,7 +17,7 @@ package io.shinhanlife.dap.mcg.presentation;
*/
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -41,7 +41,7 @@ public class ChatController {
private final ExecuteService executeService;
private final ObjectMapper objectMapper;
private final RedisRegistryService registryService;
private final InMemoryRegistryService registryService;
private final ChatClient.Builder chatClientBuilder;
private final McpRouterController mcpRouterController;

View File

@@ -21,7 +21,7 @@ import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.adapter.dto.Params;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.swagger.v3.oas.annotations.Operation;
@@ -49,19 +49,19 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
@Tag(name = "MCP Router API", description = "AI Agent의 요청을 받아 Adapter 시스템으로 라우팅하는 게이트웨이 API")
public class McpRouterController {
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService registryService;
private final ExecuteService executeService;
private final SecurityProperties securityProperties;
private final ObjectMapper objectMapper;
private final GatewayFallbackProperties gatewayFallbackProperties;
private final RestClient restClient;
public McpRouterController(RedisRegistryService redisRegistryService,
public McpRouterController(InMemoryRegistryService registryService,
ExecuteService executeService,
SecurityProperties securityProperties,
ObjectMapper objectMapper,
GatewayFallbackProperties gatewayFallbackProperties) {
this.redisRegistryService = redisRegistryService;
this.registryService = registryService;
this.executeService = executeService;
this.securityProperties = securityProperties;
this.objectMapper = objectMapper;
@@ -115,12 +115,12 @@ public class McpRouterController {
private List<ToolMetadata> fetchAllActiveTools() {
List<ToolMetadata> activeTools = new ArrayList<>();
try {
activeTools.addAll(redisRegistryService.getAllTools()
activeTools.addAll(registryService.getAllTools()
.stream()
.filter(ToolMetadata::getVisible)
.collect(Collectors.toList()));
} catch (org.springframework.data.redis.RedisConnectionFailureException exception) {
log.warn("Redis is unavailable. Fetching tools from configured fallback Tool Pods instead.");
} catch (Exception exception) {
log.warn("Registry is unavailable. Fetching tools from configured fallback Tool Pods instead.");
}
Set<String> knownTools = activeTools.stream()
@@ -166,9 +166,38 @@ public class McpRouterController {
.collect(Collectors.toList());
}
List<Map<String, Object>> mappedTools = activeTools.stream().map(t -> {
Map<String, Object> toolMap = new HashMap<>();
toolMap.put("name", t.getName());
toolMap.put("description", t.getDescription());
toolMap.put("inputSchema", t.getParametersSchema());
toolMap.put("outputSchema", t.getOutputSchema());
toolMap.put("title", t.getDisplayName());
Map<String, Object> annotations = new HashMap<>();
annotations.put("readOnlyHint", t.getReadOnlyHint() != null ? t.getReadOnlyHint() : false);
annotations.put("destructiveHint", t.getDestructiveHint() != null ? t.getDestructiveHint() : true);
annotations.put("idempotentHint", t.getIdempotentHint() != null ? t.getIdempotentHint() : false);
toolMap.put("annotations", annotations);
Map<String, Object> meta = new HashMap<>();
meta.put("version", t.getSemver());
meta.put("category_key", t.getCategoryKey());
meta.put("tags", t.getTags());
meta.put("owner_org", t.getOwnerOrg());
meta.put("legacy_interface_id", t.getMciServiceId());
meta.put("display_description", t.getDisplayDescription());
meta.put("example_queries", t.getExampleQueries());
meta.put("example_queries", t.getExampleQueries());
meta.put("required_env_keys", t.getRequiredEnvKeys());
toolMap.put("_meta", meta);
return toolMap;
}).collect(Collectors.toList());
JsonRpcResponse response = new JsonRpcResponse();
response.setId(UUID.randomUUID().toString());
response.setResult(Map.of("tools", activeTools));
response.setResult(Map.of("tools", mappedTools));
return ResponseEntity.ok(response);
}
@@ -240,23 +269,15 @@ public class McpRouterController {
@PostMapping("/registry/register")
public ResponseEntity<String> registerTool(@RequestBody ToolMetadata meta) {
redisRegistryService.saveTool(meta);
registryService.saveTool(meta);
return ResponseEntity.ok("Registered");
}
@PostMapping("/registry/deregister")
public ResponseEntity<String> deregisterTool(@RequestBody String uid) {
redisRegistryService.removeTool(uid);
registryService.removeTool(uid);
return ResponseEntity.ok("Deregistered");
}
@PostMapping("/registry/heartbeat")
public ResponseEntity<String> heartbeat(@RequestBody String uid) {
boolean success = redisRegistryService.refreshHeartbeat(uid);
if (success) {
return ResponseEntity.ok("Heartbeat updated");
} else {
return ResponseEntity.status(404).body("Tool not found");
}
}
}

View File

@@ -39,7 +39,7 @@ import org.springframework.web.bind.annotation.*;
public class ScaffoldingController {
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "Enum", "List");
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "List");
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
"inclusionai/ling-3.0-flash:free",
"openai/gpt-oss-20b:free",
@@ -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");
@@ -96,7 +96,7 @@ public class ScaffoldingController {
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));
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", List.of("example"), "", false));
}
ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions(
req.get("functionDescription"),
@@ -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()
@@ -155,6 +155,21 @@ public class ScaffoldingController {
.sorted().toList();
}
@GetMapping("/categories")
public List<String> listCategories(@RequestParam String moduleName) {
if (moduleName == null || !moduleName.matches("^dap-was-[a-z0-9-]+$")) {
throw new IllegalArgumentException("Invalid target module.");
}
String sourceDir = System.getenv("AXHUB_SOURCE_DIR");
if (sourceDir == null || sourceDir.isBlank()) sourceDir = System.getProperty("user.dir");
File bizDir = new File(sourceDir, moduleName + "/src/main/java/io/shinhanlife/dap/mcc/biz");
File[] dirs = bizDir.listFiles(File::isDirectory);
if (dirs == null) return List.of();
return Arrays.stream(dirs).map(File::getName)
.filter(name -> name.matches("^[a-z0-9]{3}$"))
.sorted().toList();
}
@PostMapping("/field-draft")
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
String description = req.getOrDefault("description", "").trim();
@@ -172,9 +187,10 @@ public class ScaffoldingController {
Generate Java DTO fields for an MCP tool.
Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape:
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List.
Enum fields must include enumValues. List fields must include itemType; use Object plus itemFields for object lists.
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, List.
Finite values should be enforced by populating enumValues on standard types. List fields must include itemType; use Object plus itemFields for object lists.
If applicable, provide a regex for pattern.
Generate fields only for the requested target: %s.
For OUTPUT fields, include resultCode and resultMessage when appropriate.
Keep field names valid Java camelCase identifiers. Generate at most 10 fields.
@@ -202,12 +218,13 @@ public class ScaffoldingController {
Generate an MCP Tool scaffold from the user request.
Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape:
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","examples":["example1","example2"],"pattern":"^regex$","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","examples":["SUCCESS"],"pattern":"","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
categoryKey must be exactly three lowercase letters or digits.
routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores.
Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields.
Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner.
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List. Enum must include enumValues; List must include itemType and object lists include itemFields.
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, List. Finite values should be enforced by populating enumValues. List must include itemType and object lists include itemFields.
If applicable, provide a regex for pattern.
Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list.
Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
User request: %s
@@ -248,12 +265,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");
}
}
@@ -287,7 +304,8 @@ public class ScaffoldingController {
field.name().trim(),
field.type() == null ? "String" : field.type().trim(),
field.description() == null ? "" : field.description().trim(),
field.example() == null ? "" : field.example().trim(),
field.examples() == null ? List.of() : field.examples().stream().map(String::trim).toList(),
field.pattern() == null ? "" : field.pattern().trim(),
field.required(),
field.enumValues() == null ? List.of() : field.enumValues(),
field.itemType(),
@@ -308,9 +326,6 @@ public class ScaffoldingController {
}
private void validateStructuredField(ToolScaffolder.FieldDefinition field) {
if ("Enum".equals(field.type()) && field.enumValues().isEmpty()) {
throw new IllegalArgumentException("Enum field needs enumValues: " + field.name());
}
if ("List".equals(field.type()) && (field.itemType() == null || field.itemType().isBlank())) {
throw new IllegalArgumentException("List field needs itemType: " + field.name());
}
@@ -375,8 +390,7 @@ public class ScaffoldingController {
return chatClientBuilder.build().prompt()
.user(prompt)
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(resolveModel(requestedModel))
.build())
.model(resolveModel(requestedModel)).build())
.call()
.content();
}

View File

@@ -0,0 +1,136 @@
package io.shinhanlife.dap.mcg.presentation;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Gateway 내부 MCP 목록 조회와 Report 서비스의 Excel API 프록시를 담당한다. */
@RestController
public class ToolReportProxyController {
private final RestClient restClient;
private final String reportServiceUrl;
private final McpRouterController mcpRouterController;
public ToolReportProxyController(
RestClient.Builder restClientBuilder,
McpRouterController mcpRouterController,
@Value("${report.service-url:http://127.0.0.1:8092}") String reportServiceUrl) {
this.restClient = restClientBuilder.build();
this.mcpRouterController = mcpRouterController;
this.reportServiceUrl = reportServiceUrl.replaceAll("/+$", "");
}
/** Tool 목록은 Report 서비스나 로컬 소스가 아니라 Gateway의 MCP Registry에서만 조회한다. */
@GetMapping(value = "/report/api/report-tools", produces = MediaType.APPLICATION_JSON_VALUE)
public List<ReportToolSummary> tools() {
return activeTools().stream().map(ReportToolSummary::from).toList();
}
@PostMapping(value = "/report/api/tool-reports/excel",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> excel(@RequestBody Map<String, Object> request) {
validateMcpSelection(request);
ResponseEntity<byte[]> response = restClient.post()
.uri(reportServiceUrl + "/api/tool-reports/excel")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.toEntity(byte[].class);
return copy(response);
}
private void validateMcpSelection(Map<String, Object> request) {
Object rawNames = request.get("toolNames");
if (!(rawNames instanceof List<?> names) || names.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "toolNames must contain at least one tool");
}
Set<String> registeredNames = activeTools().stream()
.map(ToolMetadata::getName)
.collect(java.util.stream.Collectors.toSet());
Set<String> unknown = new LinkedHashSet<>();
names.stream().map(String::valueOf)
.filter(name -> !registeredNames.contains(name))
.forEach(unknown::add);
if (!unknown.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"MCP에 등록되지 않은 툴이 포함되어 있습니다: " + unknown);
}
}
private List<ToolMetadata> activeTools() {
ResponseEntity<JsonRpcResponse> response = mcpRouterController.listTools(null);
JsonRpcResponse body = response.getBody();
if (body == null || !(body.getResult() instanceof Map<?, ?> result)
|| !(result.get("tools") instanceof List<?> tools)) {
throw new IllegalStateException("MCP tools/list response does not contain result.tools");
}
return tools.stream().map(item -> {
if (!(item instanceof ToolMetadata metadata)) {
throw new IllegalStateException("MCP tools/list contains an invalid tool entry");
}
return metadata;
}).toList();
}
private ResponseEntity<byte[]> copy(ResponseEntity<byte[]> response) {
ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode());
MediaType contentType = response.getHeaders().getContentType();
if (contentType != null) builder.contentType(contentType);
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (disposition != null) builder.header(HttpHeaders.CONTENT_DISPOSITION, disposition);
return builder.body(response.getBody());
}
public record ReportToolSummary(
String name,
String title,
String description,
String categoryKey,
String mappingId,
boolean register,
boolean requiresApproval,
boolean readOnlyHint,
boolean destructiveHint,
boolean idempotentHint,
boolean openWorldHint,
String requestType,
String responseType,
String useCaseClass,
String sourceFile,
String inputSchemaResource,
String outputSchemaResource) {
static ReportToolSummary from(ToolMetadata tool) {
String name = tool.getName() == null || tool.getName().isBlank() ? tool.getUid() : tool.getName();
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
? name : tool.getDisplayName();
return new ReportToolSummary(
name, title, value(tool.getDescription()), value(tool.getCategoryKey()),
value(tool.getMciServiceId()), Boolean.TRUE.equals(tool.getIsRegistered()),
Boolean.TRUE.equals(tool.getRequiresApproval()), Boolean.TRUE.equals(tool.getReadOnlyHint()),
Boolean.TRUE.equals(tool.getDestructiveHint()), Boolean.TRUE.equals(tool.getIdempotentHint()),
Boolean.TRUE.equals(tool.getOpenWorldHint()), "", "", "", "", "", "");
}
private static String value(String value) {
return value == null ? "" : value;
}
}
}

View File

@@ -1,264 +0,0 @@
package io.shinhanlife.dap.mcg.redis;
/**
* @package io.shinhanlife.dap.mcg.redis
* @className RedisToolTraceService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RedisToolTraceService {
private static final Logger log = LoggerFactory.getLogger(RedisToolTraceService.class);
private static final String KEY_PREFIX = "mcp:request:";
private static final String RECENT_KEY = "mcp:request:recent";
private final McpGatewayProperties properties;
private final ObjectProvider<StringRedisTemplate> redisProvider;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
public RedisToolTraceService(McpGatewayProperties properties,
ObjectProvider<StringRedisTemplate> redisProvider,
ObjectMapper json,
McpMonitorEventService monitorEvents) {
this.properties = properties;
this.redisProvider = redisProvider;
this.json = json;
this.monitorEvents = monitorEvents;
}
/**
* Agent request has entered the MCP tool gateway.
*/
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
"Agent request entered MCP", "");
}
/**
* MCP is attempting to call the target Tool server.
*/
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int attempt, int maxAttempts) {
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
"Calling tool server", "");
}
/**
* MCP will retry the Tool call after backoff.
*/
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
"Retry will be attempted after backoff", "");
}
/**
* Tool execution has finished.
*/
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
long elapsedMillis, boolean success, String failureType, String responseText) {
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
success ? "Tool call completed" : "Tool call failed", responseText);
}
/**
* Recent live trace entries. One current entry is kept per request id.
*/
public List<String> recent() {
if (!properties.redisTraceEnabled()) {
return List.of("Redis trace is disabled. Set MCP_REDIS_TRACE_ENABLED=true.");
}
StringRedisTemplate redis = redisProvider.getIfAvailable();
if (redis == null) {
return List.of("RedisTemplate is not available.");
}
try {
List<String> requestIds = redis.opsForList().range(RECENT_KEY, 0, 49);
if (requestIds == null || requestIds.isEmpty()) {
return List.of();
}
return requestIds.stream()
.map(requestId -> redis.opsForValue().get(KEY_PREFIX + requestId))
.filter(value -> value != null && !value.isBlank())
.toList();
} catch (Exception error) {
log.warn("Redis trace read failed. message={}", error.getMessage());
return List.of("Redis trace read failed: " + error.getMessage());
}
}
/**
* Historical accumulation is intentionally disabled.
*
* The MCP monitor is used to observe the current Agent request flow only,
* so this method returns the same live entries as recent().
*/
public List<String> history() {
return recent();
}
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
long elapsedMillis, String failureType, int attempt, int maxAttempts,
long backoffMillis, String message, String responseText) {
String stateKey = stateKey(context, metadata);
if (attempt > 0 && maxAttempts > 0) {
attemptStates.put(stateKey, new AttemptState(attempt, maxAttempts));
}
String payload;
try {
payload = tracePayload(context, metadata, arguments, status, elapsedMillis, failureType, attempt,
maxAttempts, backoffMillis, message, responseText);
monitorEvents.publish(payload);
if ("SUCCESS".equals(status) || "FAILED".equals(status)) {
attemptStates.remove(stateKey);
}
} catch (Exception error) {
log.warn("MCP monitor event publish failed. requestId={} tool={} message={}",
context.requestId(), metadata.getName(), error.getMessage());
return;
}
if (!properties.redisTraceEnabled()) {
return;
}
StringRedisTemplate redis = redisProvider.getIfAvailable();
if (redis == null) {
return;
}
try {
String requestId = context.requestId();
Duration ttl = Duration.ofSeconds(properties.redisTraceTtlSeconds());
redis.opsForValue().set(KEY_PREFIX + requestId, payload, ttl);
redis.opsForList().remove(RECENT_KEY, 0, requestId);
redis.opsForList().leftPush(RECENT_KEY, requestId);
redis.opsForList().trim(RECENT_KEY, 0, 49);
redis.expire(RECENT_KEY, ttl);
} catch (Exception error) {
log.warn("Redis trace save failed. requestId={} tool={} message={}",
context.requestId(), metadata.getName(), error.getMessage());
}
}
String tracePayload(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
long elapsedMillis, String failureType, int attempt, int maxAttempts,
long backoffMillis, String message, String responseText) throws Exception {
AttemptState state = attemptStates.get(stateKey(context, metadata));
int displayAttempt = attempt > 0 ? attempt : state == null ? 0 : state.attempt();
int displayMaxAttempts = maxAttempts > 0 ? maxAttempts : state == null ? 0 : state.maxAttempts();
int retryCount = Math.max(0, displayAttempt - 1);
Map<String, Object> trace = new LinkedHashMap<>();
trace.put("requestId", context.requestId());
trace.put("traceGroupId", context.traceGroupId());
trace.put("agentId", context.agentId());
trace.put("userId", context.userId());
trace.put("clientAddress", context.clientAddress());
trace.put("toolName", metadata.getName());
trace.put("operationType", metadata.getOperationType() != null ? metadata.getOperationType().name() : "READ");
trace.put("status", status);
trace.put("message", message == null ? "" : message);
trace.put("failureType", failureType == null ? "" : failureType);
trace.put("attempt", displayAttempt);
trace.put("maxAttempts", displayMaxAttempts);
trace.put("retryCount", retryCount);
trace.put("backoffMillis", backoffMillis);
trace.put("elapsedMillis", elapsedMillis);
trace.put("idempotencyKeyHash", hashText(arguments.path("idempotencyKey").asText("")));
List<String> argNames = new ArrayList<>();
arguments.fieldNames().forEachRemaining(argNames::add);
trace.put("argumentNames", argNames);
trace.put("arguments", arguments.toString());
trace.put("responseSummary", responseSummary(responseText));
trace.put("timestamp", Instant.now().toString());
return json.writeValueAsString(trace);
}
/**
* Redis Trace에는 Tool 응답 원문을 저장하지 않고 size/hash/상태/카운트만 저장합니다.
*/
Map<String, Object> responseSummary(String responseText) {
Map<String, Object> summary = new LinkedHashMap<>();
if (responseText == null || responseText.isBlank()) {
summary.put("bytes", 0);
summary.put("sha256", "");
return summary;
}
byte[] bytes = responseText.getBytes(StandardCharsets.UTF_8);
summary.put("bytes", bytes.length);
summary.put("sha256", sha256(bytes));
try {
JsonNode root = json.readTree(responseText);
summary.put("success", root.path("success").asBoolean(false));
summary.put("status", root.path("status").asText(""));
summary.put("toolName", root.path("toolName").asText(""));
summary.put("pageSize", root.path("pageSize").asInt(0));
summary.put("pageCount", root.path("pageCount").asInt(0));
summary.put("returnedCount", root.path("returnedCount").asInt(0));
summary.put("totalCount", root.path("totalCount").asLong(0));
summary.put("hasMore", root.path("hasMore").asBoolean(false));
summary.put("nextCursor", root.path("nextCursor").asText(""));
summary.put("message", root.path("message").asText(""));
summary.put("resultRef", root.path("resultRef").asText(""));
} catch (Exception ignored) {
summary.put("parseable", false);
}
return summary;
}
private String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception error) {
return "";
}
}
private String hashText(String value) {
if (value == null || value.isBlank()) {
return "";
}
return sha256(value.getBytes(StandardCharsets.UTF_8));
}
private String stateKey(McpRequestContext context, ToolMetadata metadata) {
return context.requestId() + ":" + metadata.getName();
}
private record AttemptState(int attempt, int maxAttempts) {
}
}

View File

@@ -0,0 +1,78 @@
package io.shinhanlife.dap.mcg.registry;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* @package io.shinhanlife.dap.mcg.registry
* @className InMemoryRegistryService
* @description AX HUB 인메모리 툴 레지스트리
* @author 0986406
* @create 2026.09.01
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InMemoryRegistryService {
private final Map<String, ToolMetadata> toolCache = new ConcurrentHashMap<>();
private static final long DEFAULT_TTL_MILLIS = 45000; // 45초
/**
* 툴 등록 및 갱신
*/
public void saveTool(ToolMetadata meta) {
toolCache.put(meta.getUid(), meta);
log.info(" [InMemoryRegistry] 툴 등록 완료: {}", meta.getUid());
}
public List<String> getAvailablePods(String uid) {
ToolMetadata tool = getTool(uid);
if (tool != null && tool.getPodUrl() != null) {
return List.of(tool.getPodUrl());
}
return List.of();
}
/**
* 실행 시 툴 정보 조회
*/
public ToolMetadata getTool(String uid) {
return toolCache.get(uid);
}
/**
* 툴 이름으로 정보 조회
*/
public ToolMetadata getToolByName(String name) {
for (ToolMetadata tool : toolCache.values()) {
if (name.equals(tool.getName())) {
return tool;
}
}
return null;
}
/**
* 등록된 모든 활성 툴 목록 조회
*/
public List<ToolMetadata> getAllTools() {
return List.copyOf(toolCache.values());
}
/**
* 툴 명시적 제거 (Deregister)
*/
public void removeTool(String uid) {
toolCache.remove(uid);
log.info(" [InMemoryRegistry] 툴 삭제 완료: {}", uid);
}
}

View File

@@ -1,110 +0,0 @@
package io.shinhanlife.dap.mcg.registry;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Objects;
/**
* @package io.shinhanlife.dap.mcg.registry
* @className RedisRegistryService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisRegistryService {
private final RedisTemplate<String, ToolMetadata> redisTemplate;
private static final String KEY_PREFIX = "mcp:tool:";
private static final Duration DEFAULT_TTL = Duration.ofSeconds(45); // 실무 하트비트 주기 반영
/**
* 툴 등록 및 갱신 (TTL 기반으로 60초 뒤 자동 만료)
*/
public void saveTool(ToolMetadata meta) {
String key = KEY_PREFIX + meta.getUid();
meta.setLastHeartbeat(System.currentTimeMillis());
redisTemplate.opsForValue().set(key, meta, DEFAULT_TTL);
log.info(" [RedisRegistry] 툴 등록 완료: {}", meta.getUid());
}
/**
* 하트비트 갱신 (TTL 초기화)
*/
public boolean refreshHeartbeat(String uid) {
String key = KEY_PREFIX + uid;
Boolean exists = redisTemplate.expire(key, DEFAULT_TTL);
if (Boolean.TRUE.equals(exists)) {
log.debug(" [RedisRegistry] 하트비트 갱신: {}", uid);
return true;
} else {
log.warn(" [RedisRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid);
return false;
}
}
public List<String> getAvailablePods(String uid) {
ToolMetadata tool = getTool(uid);
if (tool != null && tool.getPodUrl() != null) {
return List.of(tool.getPodUrl());
}
return List.of();
}
/**
* 실행 시 툴 정보 조회 (Tool Execution 시 참조)
*/
public ToolMetadata getTool(String uid) {
return redisTemplate.opsForValue().get(KEY_PREFIX + uid);
}
/**
* 툴 이름으로 정보 조회
*/
public ToolMetadata getToolByName(String name) {
List<ToolMetadata> allTools = getAllTools();
for (ToolMetadata tool : allTools) {
if (name.equals(tool.getName())) {
return tool;
}
}
return null;
}
/**
* 등록된 모든 활성 툴 목록 조회
*/
public List<ToolMetadata> getAllTools() {
Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return List.of();
}
List<ToolMetadata> tools = redisTemplate.opsForValue().multiGet(keys);
return tools != null ? tools.stream().filter(Objects::nonNull).collect(Collectors.toList()) : List.of();
}
/**
* 툴 명시적 제거 (Deregister)
*/
public void removeTool(String uid) {
redisTemplate.delete(KEY_PREFIX + uid);
log.info(" [RedisRegistry] 툴 삭제 완료: {}", uid);
}
}

View File

@@ -30,7 +30,7 @@ import io.shinhanlife.dap.mcg.audit.AuditLogService;
import io.shinhanlife.dap.mcg.resilience.CircuitBreaker;
import io.shinhanlife.dap.mcg.resilience.CircuitBreakerService;
import io.shinhanlife.dap.mcg.security.ToolAuthorizationService;
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
import io.shinhanlife.dap.mcg.trace.InMemoryToolTraceService;
import jakarta.annotation.PreDestroy;
import java.util.Map;
@@ -62,7 +62,7 @@ public class ExecuteService {
private final AuditLogService auditLogService;
private final CircuitBreakerService circuitBreakerService;
private final ToolAuthorizationService authorizationService;
private final RedisToolTraceService redisTrace;
private final InMemoryToolTraceService traceService;
private final McpGatewayProperties properties;
private final LargeToolResponseService largeResponses;
private final PaginationRequestValidator paginationValidator;
@@ -79,7 +79,7 @@ public class ExecuteService {
AuditLogService auditLogService,
CircuitBreakerService circuitBreakerService,
ToolAuthorizationService authorizationService,
RedisToolTraceService redisTrace,
InMemoryToolTraceService traceService,
McpGatewayProperties properties,
LargeToolResponseService largeResponses,
PaginationRequestValidator paginationValidator,
@@ -94,7 +94,7 @@ public class ExecuteService {
this.auditLogService = auditLogService;
this.circuitBreakerService = circuitBreakerService;
this.authorizationService = authorizationService;
this.redisTrace = redisTrace;
this.traceService = traceService;
this.properties = properties;
this.largeResponses = largeResponses;
this.paginationValidator = paginationValidator;
@@ -133,7 +133,7 @@ public class ExecuteService {
long startedAt = System.nanoTime();
auditLogService.toolStarted(context, toolName, argumentsNode);
redisTrace.started(context, metadata, argumentsNode);
traceService.started(context, metadata, argumentsNode);
try {
authorizationService.authorize(context, metadata);
@@ -174,13 +174,13 @@ public class ExecuteService {
finalResult.put("original_size", originalSize);
auditLogService.toolFinished(context, metadata.getName(), elapsedMillis, true, "");
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
traceService.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
return finalResult;
} catch (ToolExecutionException error) {
long elapsedMillis = elapsedMillis(startedAt);
auditLogService.toolFinished(context, toolName, elapsedMillis, false, error.failureType().name());
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
errorResult.put("status", "error");
@@ -194,7 +194,7 @@ public class ExecuteService {
} catch (Exception error) {
long elapsedMillis = elapsedMillis(startedAt);
auditLogService.toolFinished(context, toolName, elapsedMillis, false, FailureType.INTERNAL_ERROR.name());
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
errorResult.put("status", "error");
@@ -225,7 +225,7 @@ public class ExecuteService {
ToolExecutionException lastError = null;
for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) {
try {
redisTrace.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
traceService.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
circuitBreaker.beforeCall();
Object result = executeOnce(context, metadata, arguments, payload);
circuitBreaker.recordSuccess();
@@ -237,7 +237,7 @@ public class ExecuteService {
throw error;
}
long backoffMillis = retryPolicy.backoffMillis(attempt);
redisTrace.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
traceService.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
error.failureType().name());
sleepBeforeRetry(metadata.getName(), attempt, backoffMillis, error.failureType());
}

View File

@@ -2,34 +2,29 @@ package io.shinhanlife.dap.mcg.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @package io.shinhanlife.dap.mcg.service
* @className KillSwitchService
* @description AX HUB 시스템 처리 클래스
* @description AX HUB 시스템 킬 스위치 서비스 (인메모리)
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KillSwitchService {
// 기본 제공되는 StringRedisTemplate 사용
private final StringRedisTemplate stringRedisTemplate;
// 인메모리 맵으로 변경
private final Map<String, String> killSwitchMap = new ConcurrentHashMap<>();
public void checkAgent(String tenantId) {
String key = "mcp:kill:agent:" + tenantId;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 에이전트 접근 시도: {}", tenantId);
throw new SecurityException(" 비상 차단: 해당 테넌트(" + tenantId + ")의 접근이 관리자에 의해 차단되었습니다.");
}
@@ -39,7 +34,7 @@ public class KillSwitchService {
if (toolName == null || toolName.isBlank()) return;
String key = "mcp:kill:tool:" + toolName;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 툴 실행 시도: {}", toolName);
throw new SecurityException(" 비상 차단: 해당 툴(" + toolName + ")의 실행이 관리자에 의해 차단되었습니다.");
}
@@ -49,7 +44,7 @@ public class KillSwitchService {
if (integrationType == null || integrationType.isBlank()) return;
String key = "mcp:kill:route:" + integrationType;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 라우트 통신 시도: {}", integrationType);
throw new SecurityException(" 비상 차단: 해당 레거시 라우트(" + integrationType + ")로의 통신이 관리자에 의해 차단되었습니다.");
}
@@ -58,14 +53,14 @@ public class KillSwitchService {
// 관리자 API용 토글 메서드
public void toggleKillSwitch(String type, String target, boolean state) {
String key = "mcp:kill:" + type + ":" + target;
stringRedisTemplate.opsForValue().set(key, String.valueOf(state));
killSwitchMap.put(key, String.valueOf(state));
log.info(" [KillSwitch] 상태 변경: {} -> {} (차단 상태: {})", type, target, state);
}
// 관리자 API용 삭제 메서드 (Redis 키 자체를 완전 삭제)
// 관리자 API용 삭제 메서드 (맵 항목 삭제)
public void removeKillSwitch(String type, String target) {
String key = "mcp:kill:" + type + ":" + target;
stringRedisTemplate.delete(key);
killSwitchMap.remove(key);
log.info(" [KillSwitch] 차단 키 완전 삭제: {} -> {}", type, target);
}
}

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcg.service;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import lombok.RequiredArgsConstructor;
@@ -14,14 +14,14 @@ import java.util.Map;
/**
* @package io.shinhanlife.dap.mcg.service
* @className ToolPlanner
* @description AX HUB 시스템 처리 클래스
* @description AX HUB ?<3F>스??처리 ?<3F>래??
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정력 ----------
* 수정일 수정자 수정내
* ---------- 개정?<3F>력 ----------
* ?<3F>정?? ?<3F>정?? ?<3F>정?<3F>
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초
* 2026.09.01 0986406 최초?<3F>
*
* </pre>
*/
@@ -30,30 +30,30 @@ import java.util.Map;
@RequiredArgsConstructor
public class ToolPlanner {
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService InMemoryRegistryService;
private final SecurityProperties securityProperties;
private final GatewayFallbackProperties fallbackProperties;
/**
* 청(payload)을 분석하여 실행해야 할 Tool의 계획을 생성합니다.
* ?<3F>청(payload)??분석?<3F>여 ?<3F>행?<3F>야 ??Tool??계획???<3F>성?<3F>니??
*/
public Object createPlan(Map<String, Object> payload, String tenantId) {
log.info(" [Planner] 청 분석 및 실행 계획 수립 시");
log.info(" [Planner] ?<3F>청 분석 <EFBFBD>??<3F>행 계획 ?<3F>립 ?<3F>");
// 1. 요청에서 호출하려는 툴 이름 추출 (JSON-RPC params.name)
// 1. ?<3F>청?<3F>서 ?<3F>출?<3F>려?????<3F>름 추출 (JSON-RPC params.name)
Map<String, Object> params = (Map<String, Object>) payload.get("params");
String toolName = params != null ? (String) params.get("name") : null;
if (toolName == null || toolName.isEmpty()) {
throw new IllegalArgumentException("요청에 toolName이 포함되어 있지 않습니다.");
throw new IllegalArgumentException("?<3F>청??toolName???<3F>함?<3F>어 ?<3F><>? ?<3F>습?<3F>다.");
}
// 2. RedisRegistry에서 해당 툴의 메타데이터 조회
// (제로는 이 메타데이터가 실행 계획의 핵심이 됩니다)
var toolMetadata = redisRegistryService.getToolByName(toolName);
// 2. RedisRegistry?<3F>서 ?<3F>당 ?<3F>의 메<>??<3F>이??조회
// (?<3F>제로는 ??메<>??<3F>이?<3F><>? ?<3F>행 계획???<3F>심???<3F>니??
var toolMetadata = InMemoryRegistryService.getToolByName(toolName);
if (toolMetadata == null) {
log.warn(" [Planner] 등록되지 않은 툴 요청: {}. Fallback 라우팅 규칙을 확인합니다.", toolName);
log.warn(" [Planner] ?<3F>록?<3F><>? ?<3F><>? ???<3F>청: {}. Fallback ?<3F>우??규칙???<3F>인?<3F>니??", toolName);
String fallbackPodUrl = fallbackProperties.getDefaultUrl();
if (fallbackProperties.getRoutes() != null) {
@@ -66,11 +66,11 @@ public class ToolPlanner {
}
if (fallbackPodUrl == null || fallbackPodUrl.isEmpty()) {
log.error(" [Planner] Fallback 라우팅 대상이 아닙니다. 툴: {}", toolName);
throw new RuntimeException("해당 툴(" + toolName + ")이 레지스트리에 존재하지 않습니다.");
log.error(" [Planner] Fallback ?<3F>우???<3F>?<3F>이 ?<3F>닙?<3F>다. ?? {}", toolName);
throw new RuntimeException("?<3F>당 ??" + toolName + ")???<3F><>??<3F>트리에 존재?<3F><>? ?<3F>습?<3F>다.");
}
log.info(" [Planner] Fallback 라우팅 매칭됨: {} -> {}", toolName, fallbackPodUrl);
log.info(" [Planner] Fallback ?<3F>우??매칭?? {} -> {}", toolName, fallbackPodUrl);
toolMetadata = ToolMetadata.builder()
.uid(toolName)
@@ -80,26 +80,26 @@ public class ToolPlanner {
.build();
}
// 2-1. [규] 도메인 그룹핑 기반 권한 검
// 2-1. [?<3F>규] ?<3F>메??그룹??기반 권한 검<EFBFBD>?
if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
String normalizedTenantId = tenantId.toLowerCase();
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);
// 만약 대소문자 변환 후에도 없으면 원래 값으로 한 번 더 시도 (하위 호환성)
// 만약 ?<3F>?<3F>문??변???<3F>에???<3F><EFBFBD>??<3F>래 값으<EAB092>???<3F>????<3F>도 (?<3F>위 ?<3F>환??
if (allowedDomains == null) {
allowedDomains = securityProperties.getTenantDomains().get(tenantId);
}
if (allowedDomains == null ||
(!allowedDomains.contains("ALL") && !allowedDomains.contains(toolMetadata.getCategoryKey()))) {
log.warn(" [Planner] 권한 거 - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
throw new SecurityException("해당 도메인(" + toolMetadata.getCategoryKey() + ")의 툴을 실행할 권한이 없습니다.");
log.warn(" [Planner] 권한 거<EFBFBD>? - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
throw new SecurityException("?<3F>당 ?<3F>메??" + toolMetadata.getCategoryKey() + ")???<3F>을 ?<3F>행??권한???<3F>습?<3F>다.");
}
}
log.info(" [Planner] '{}'에 대한 실행 계획 수립 완", toolName);
log.info(" [Planner] ??'{}'???<3F>???<3F>행 계획 ?<3F>립 ?<3F>", toolName);
// 3. 수립된 계획(툴 메타데이터) 반환
// 3. ?<3F>립??계획(??메<>??<3F>이?? 반환
return toolMetadata;
}
}

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

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcg.sync;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.modelcontextprotocol.server.McpSyncServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,18 +38,18 @@ public class RegistryMcpToolSynchronizer {
private final ObjectProvider<McpSyncServer> mcpServerProvider;
private final DynamicMcpServerManager dynamicMcpServerManager;
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService InMemoryRegistryService;
private final RegistryMcpToolSpecificationFactory specificationFactory;
private final Set<String> managedToolNames = new LinkedHashSet<>();
private final ReentrantLock lock = new ReentrantLock();
public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider,
DynamicMcpServerManager dynamicMcpServerManager,
RedisRegistryService redisRegistryService,
InMemoryRegistryService InMemoryRegistryService,
RegistryMcpToolSpecificationFactory specificationFactory) {
this.mcpServerProvider = mcpServerProvider;
this.dynamicMcpServerManager = dynamicMcpServerManager;
this.redisRegistryService = redisRegistryService;
this.InMemoryRegistryService = InMemoryRegistryService;
this.specificationFactory = specificationFactory;
}
@@ -66,7 +66,7 @@ public class RegistryMcpToolSynchronizer {
public void synchronize() {
lock.lock();
try {
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
List<ToolMetadata> activeEntries = InMemoryRegistryService.getAllTools()
.stream().filter(ToolMetadata::getVisible).collect(Collectors.toList());
// 1. Dynamic MCP Server 동기화 (카테고리별)

View File

@@ -0,0 +1,172 @@
package io.shinhanlife.dap.mcg.trace;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @package io.shinhanlife.dap.mcg.trace
* @className InMemoryToolTraceService
* @description AX HUB 시스템 인메모리 툴 트레이스 서비스
* @author 0986406
* @create 2026.09.01
*/
@Service
public class InMemoryToolTraceService {
private static final Logger log = LoggerFactory.getLogger(InMemoryToolTraceService.class);
private final McpGatewayProperties properties;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
// LRU Cache for recent traces
private final Map<String, String> recentTraces;
public InMemoryToolTraceService(McpGatewayProperties properties,
ObjectMapper json,
McpMonitorEventService monitorEvents) {
this.properties = properties;
this.json = json;
this.monitorEvents = monitorEvents;
final int maxSize = (int) properties.traceRetentionSize();
this.recentTraces = new LinkedHashMap<String, String>(maxSize, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > maxSize;
}
};
}
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
"Agent request entered MCP", "");
}
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int attempt, int maxAttempts) {
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
"Calling tool server", "");
}
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
"Retry will be attempted after backoff", "");
}
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
long elapsedMillis, boolean success, String failureType, String responseText) {
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
success ? "Tool call completed" : "Tool call failed", responseText);
}
public List<String> getRecentTraces(int limit) {
if (!properties.traceEnabled()) {
return List.of("Trace is disabled. Set traceEnabled=true.");
}
List<String> result = new ArrayList<>();
synchronized (recentTraces) {
List<String> values = new ArrayList<>(recentTraces.values());
// reverse to get latest first
for (int i = values.size() - 1; i >= 0 && result.size() < limit; i--) {
result.add(values.get(i));
}
}
return result;
}
public String getTraceDetail(String requestId) {
if (!properties.traceEnabled()) {
return "Trace is disabled.";
}
synchronized (recentTraces) {
return recentTraces.get(requestId);
}
}
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
String state, long elapsedMillis, String failureType, int attempt, int maxAttempts, long backoffMillis,
String message, String responseText) {
if (!properties.traceEnabled()) {
return; // trace is off
}
try {
AttemptState ast = attemptStates.computeIfAbsent(context.requestId(), k -> new AttemptState(0));
if (attempt > 0) ast.currentAttempt = attempt;
ObjectNode root = json.createObjectNode();
root.put("requestId", context.requestId());
root.put("agentId", context.agentId());
root.put("tenantId", context.traceGroupId());
root.put("toolName", metadata.getName());
root.put("targetPod", metadata.getPodUrl());
root.put("state", state);
root.put("timestamp", Instant.now().toString());
root.put("elapsedMillis", elapsedMillis);
root.put("attempt", ast.currentAttempt);
root.put("maxAttempts", maxAttempts > 0 ? maxAttempts : properties.retryMaxAttempts());
root.put("backoffMillis", backoffMillis);
if (failureType != null && !failureType.isEmpty()) {
root.put("failureType", failureType);
}
if (message != null && !message.isEmpty()) {
root.put("message", message);
}
// Mask arguments if needed, for simplicity we just put them
root.set("arguments", arguments);
if (responseText != null && !responseText.isEmpty()) {
if (responseText.length() > 500) {
root.put("responsePreview", responseText.substring(0, 500) + "...");
} else {
root.put("responsePreview", responseText);
}
}
String payload = json.writeValueAsString(root);
synchronized (recentTraces) {
recentTraces.put(context.requestId(), payload);
}
monitorEvents.publish(payload);
if ("SUCCESS".equals(state) || "FAILED".equals(state)) {
attemptStates.remove(context.requestId());
}
} catch (Exception e) {
log.error("Failed to save trace: {}", e.getMessage());
}
}
private static class AttemptState {
int currentAttempt;
AttemptState(int currentAttempt) {
this.currentAttempt = currentAttempt;
}
}
}

View File

@@ -1,8 +1,8 @@
package io.shinhanlife.dap.mcg.redis;
package io.shinhanlife.dap.mcg.trace;
/**
* @package io.shinhanlife.dap.mcg.redis
* @package io.shinhanlife.dap.mcg.trace
* @className McpMonitorEventService
* @description AX HUB 시스템 처리 클래스
* @author 0986406

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

@@ -824,6 +824,50 @@
<!-- Tool Creation Form -->
<div class="tab-pane fade" id="tool" role="tabpanel">
<form id="toolForm">
<div class="row mb-4">
<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-cus">dap-was-cus</option>
<option value="dap-was-hr">dap-was-hr</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>
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Gateway Registration</label>
<select class="form-select" name="register">
<option value="true" selected>True (Public)</option>
<option value="false">False (Private)</option>
</select>
<div class="input-hint">Controls dynamic routing exposure.</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label mb-1">Domain Category</label>
<div class="row g-2">
<div class="col-md-5">
<select id="domainCategorySelect" class="form-select">
<option value="">새 도메인 생성</option>
</select>
</div>
<div class="col-md-7">
<input type="text" id="domainCategoryName" class="form-control" name="categoryKey" pattern="^[a-z0-9]{3}$" maxlength="3" minlength="3" placeholder="e.g. cmm (3글자 영소문자)" oninput="this.value = this.value.toLowerCase()" required>
</div>
</div>
</div>
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Protocol</label>
<select class="form-select" name="routingType">
<option value="HTTP">HTTP (REST)</option>
<option value="MCI">MCI (Legacy)</option>
</select>
</div>
</div>
<div class="mb-4 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
<div class="d-flex justify-content-between align-items-center gap-3">
<div>
@@ -920,26 +964,6 @@
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Domain Category</label>
<input type="text" class="form-control" name="categoryKey" pattern="^[a-z0-9]{3}$" maxlength="3" minlength="3" list="groupList" placeholder="e.g. cmm, smp, pay" oninput="this.value = this.value.toLowerCase()" required>
<datalist id="groupList">
<option value="smp">smp</option>
<option value="cmm">cmm</option>
<option value="cus">cus</option>
<option value="cnt">cnt</option>
<option value="clm">clm</option>
</datalist>
</div>
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Protocol</label>
<select class="form-select" name="routingType">
<option value="HTTP">HTTP (REST)</option>
<option value="MCI">MCI (Legacy)</option>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
@@ -977,24 +1001,6 @@
</div>
</div>
<div class="row mb-4">
<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-hr">dap-was-hr</option>
<option value="dap-was-sms">dap-was-sms</option>
</select>
<div class="input-hint">Destination Pod directory.</div>
</div>
<div class="col-md-6 mt-3 mt-md-0">
<label class="form-label">Gateway Registration</label>
<select class="form-select" name="register">
<option value="false" selected>False (Private)</option>
</select>
<div class="input-hint">Controls dynamic routing exposure.</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
@@ -1036,7 +1042,7 @@
<div class="docgen-field">
<label class="docgen-field-label" for="docgenToolSelect">Tool</label>
<select id="docgenToolSelect" disabled><option>Tool Metadata를 불러오는 중...</option></select>
<div id="docgenToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
<div id="docgenGrowToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
</div>
<div class="docgen-two-col docgen-field">
@@ -1175,7 +1181,8 @@
<th style="min-width: 170px;">Enum values / List item type</th>
<th style="min-width: 260px;">Object list item fields (JSON)</th>
<th style="min-width: 210px;">Description</th>
<th style="min-width: 180px;">Example</th>
<th style="min-width: 150px;">Pattern (Regex)</th>
<th style="min-width: 180px;">Examples (Enter 단위 구분)</th>
<th style="min-width: 125px;">Required</th>
<th style="width: 1%;">삭제</th>
</tr>
@@ -1197,6 +1204,42 @@
</div>
</div>
<!-- Sub Field Editor Modal -->
<div class="modal fade" id="subFieldEditorModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">하위 필드(itemFields) 편집</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:15%">Name</th>
<th style="width:15%">Type</th>
<th style="width:15%">Details</th>
<th style="width:15%">Description</th>
<th style="width:10%">Pattern</th>
<th style="width:15%">Examples</th>
<th style="width:10%">Required</th>
<th style="width:5%"></th>
</tr>
</thead>
<tbody id="subFieldEditorBody"></tbody>
</table>
</div>
<button type="button" class="btn-action mt-2" onclick="addSubFieldEditorRow()">+ 하위 필드 추가</button>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary-action" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn-action" onclick="applySubFieldEditor()">적용</button>
</div>
</div>
</div>
</div>
<!-- Edit Tool Modal -->
<div class="modal fade" id="editToolModal" tabindex="-1" aria-labelledby="editToolModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -1276,7 +1319,7 @@
const button = dg('docgenGenerateBtn');
select.disabled = true;
button.disabled = true;
dg('docgenToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
dg('docgenGrowToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
try {
const response = await fetch('/mcp/api/v1/tools/list', { cache: 'no-store' });
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
@@ -1291,7 +1334,7 @@
`<option value="${index}">[${escapeHtml(tool.categoryKey || 'etc')}] ${escapeHtml(tool.name || tool.displayName || tool.uid)}</option>`
).join('');
select.disabled = state.tools.length === 0;
dg('docgenToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
dg('docgenGrowToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
if (state.tools.length) {
select.value = '0';
selectTool();
@@ -1303,7 +1346,7 @@
state.selected = null;
showMetadataError(error.message);
select.innerHTML = '<option>Tool Metadata 조회 실패</option>';
dg('docgenToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
dg('docgenGrowToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
}
}
@@ -1478,7 +1521,10 @@
.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('');
}
if (typeof loadCategoriesForSelection === 'function') {
loadCategoriesForSelection();
}
})
.catch(err => console.error('Failed to load modules:', err));
@@ -1566,8 +1612,37 @@
}
}
document.querySelector('#toolForm [name="categoryKey"]').addEventListener('change', loadUseCasesForSelection);
document.getElementById('targetModuleSelect').addEventListener('change', loadUseCasesForSelection);
async function loadCategoriesForSelection() {
const moduleName = document.getElementById('targetModuleSelect').value;
const select = document.getElementById('domainCategorySelect');
const nameInput = document.getElementById('domainCategoryName');
select.innerHTML = '<option value="">새 도메인 생성</option>';
try {
const response = await fetch(`/api/v1/scaffold/categories?moduleName=${encodeURIComponent(moduleName)}`);
if (!response.ok) throw new Error('Category 목록 조회 실패');
const categories = await response.json();
categories.forEach(cat => select.add(new Option(`기존 ${cat} 도메인`, cat)));
} catch (error) {
console.warn(error);
}
nameInput.value = '';
nameInput.readOnly = false;
loadUseCasesForSelection();
}
document.getElementById('domainCategoryName').addEventListener('input', loadUseCasesForSelection);
document.getElementById('targetModuleSelect').addEventListener('change', loadCategoriesForSelection);
document.getElementById('domainCategorySelect').addEventListener('change', function() {
const nameInput = document.getElementById('domainCategoryName');
if (this.value) {
nameInput.value = this.value;
nameInput.readOnly = true;
} else {
nameInput.value = '';
nameInput.readOnly = false;
}
loadUseCasesForSelection();
});
document.getElementById('toolGroupUseCaseSelect').addEventListener('change', function() {
const nameInput = document.getElementById('toolGroupUseCaseName');
if (this.value) {
@@ -1587,25 +1662,42 @@
}
document.getElementById('toolForm').addEventListener('submit', function(e) {
if (!groupedTools.length) return;
e.preventDefault();
e.stopImmediatePropagation();
try {
const useCaseName = document.getElementById('toolGroupUseCaseName').value.trim();
if (!/^[A-Z][A-Za-z0-9]*$/.test(useCaseName)) throw new Error('UseCase 이름은 PascalCase로 입력해주세요. 예: Customer');
const current = currentToolDefinition();
const tools = groupedTools.some(item => item.methodName === current.methodName) ? groupedTools : [...groupedTools, current];
const data = Object.fromEntries(new FormData(this).entries());
fetch('/api/v1/scaffold/tool-group', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({useCaseName, moduleName: data.moduleName, author: data.author, date: data.date, tools})
}).then(response => response.text()).then(result => {
const resultBox = document.getElementById('resultBox');
resultBox.style.display = 'block'; resultBox.className = result.startsWith('Error:') ? 'error' : 'success';
resultBox.textContent = result;
});
} catch (error) {
alert(error.message);
const useCaseName = document.getElementById('toolGroupUseCaseName').value.trim();
if (useCaseName) {
e.preventDefault();
e.stopImmediatePropagation();
if (!/^[A-Z][A-Za-z0-9]*$/.test(useCaseName)) {
alert('UseCase 이름은 PascalCase로 입력해주세요. 예: Customer');
return;
}
try {
const current = currentToolDefinition();
const tools = groupedTools.some(item => item.methodName === current.methodName) ? groupedTools : [...groupedTools, current];
const data = Object.fromEntries(new FormData(this).entries());
const btn = this.querySelector('button[type="submit"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 처리중...';
btn.disabled = true;
fetch('/api/v1/scaffold/tool-group', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({useCaseName, moduleName: data.moduleName, author: data.author, date: data.date, tools})
}).then(response => response.text()).then(result => {
const resultBox = document.getElementById('resultBox');
resultBox.style.display = 'block'; resultBox.className = result.startsWith('Error:') ? 'error' : 'success';
resultBox.textContent = result + (result.startsWith('Error:') ? '' : "\n\n> Note: Please sync Gradle in your IDE to reflect source code generation.");
}).catch(error => {
const resultBox = document.getElementById('resultBox');
resultBox.style.display = 'block'; resultBox.className = 'error';
resultBox.textContent = "Network error: " + error;
}).finally(() => {
btn.innerHTML = originalText;
btn.disabled = false;
});
} catch (error) {
alert(error.message);
}
}
});
@@ -1638,7 +1730,7 @@
}
}
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'Enum', 'List'];
const fieldTypes = ['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal', 'List'];
const typeExamples = {
String: 'example',
Integer: '1',
@@ -1646,7 +1738,6 @@
Double: '1.0',
Boolean: 'true',
BigDecimal: '1000.00',
Enum: 'OPEN',
List: 'C001'
};
const fieldTemplates = {
@@ -1688,6 +1779,85 @@
]
}
};
let currentSubFieldTargetInput = null;
let subFieldEditorModalInstance = null;
function openSubFieldEditor(inputEl) {
currentSubFieldTargetInput = inputEl;
let fields = [];
if (inputEl.value.trim()) {
try { fields = JSON.parse(inputEl.value); } catch(e) {}
}
document.getElementById('subFieldEditorBody').replaceChildren();
(fields.length ? fields : [{}]).forEach(addSubFieldEditorRow);
if (!subFieldEditorModalInstance) {
subFieldEditorModalInstance = new bootstrap.Modal(document.getElementById('subFieldEditorModal'));
}
subFieldEditorModalInstance.show();
}
function addSubFieldEditorRow(field = {}) {
const row = document.createElement('tr');
const makeInput = (val, ph) => {
const el = document.createElement('input');
el.type = 'text'; el.className = 'form-control form-control-sm';
el.value = val || ''; el.placeholder = ph;
return el;
};
const n = makeInput(field.name, 'name'); n.dataset.field = 'name';
const d = makeInput(field.description, 'desc'); d.dataset.field = 'description';
const p = makeInput(field.pattern, 'pattern'); p.dataset.field = 'pattern';
const e = document.createElement('textarea');
e.className = 'form-control form-control-sm';
e.rows = 2; e.dataset.field = 'example';
e.value = (field.examples || []).join('\n') || '';
const t = document.createElement('select');
t.className = 'form-select form-select-sm'; t.dataset.field = 'type';
['String', 'Integer', 'Long', 'Double', 'Boolean', 'BigDecimal'].forEach(type => {
t.add(new Option(type, type, false, (field.type || 'String') === type));
});
const det = makeInput(field.type !== 'List' ? (field.enumValues||[]).join(',') : '', 'Enum csv (콤마 구분)');
det.dataset.field = 'details';
t.addEventListener('change', () => {
det.placeholder = 'Enum csv (콤마 구분)';
});
const req = document.createElement('select');
req.className = 'form-select form-select-sm'; req.dataset.field = 'required';
req.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
req.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
[n, t, det, d, p, e, req].forEach(ctrl => {
const td = document.createElement('td'); td.appendChild(ctrl); row.appendChild(td);
});
const del = document.createElement('button');
del.type = 'button'; del.className = 'btn-secondary-action'; del.textContent = 'X';
del.style.padding = '0.1rem 0.4rem';
del.onclick = () => row.remove();
const delTd = document.createElement('td'); delTd.appendChild(del); row.appendChild(delTd);
document.getElementById('subFieldEditorBody').appendChild(row);
}
function applySubFieldEditor() {
const fields = [...document.querySelectorAll('#subFieldEditorBody tr')].map(row => {
const type = row.querySelector('[data-field="type"]').value;
const details = row.querySelector('[data-field="details"]').value.trim();
return {
name: row.querySelector('[data-field="name"]').value.trim(),
type,
description: row.querySelector('[data-field="description"]').value.trim(),
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
examples: row.querySelector('[data-field="example"]').value.split('\n').map(s=>s.trim()).filter(Boolean),
required: row.querySelector('[data-field="required"]').value === 'true',
enumValues: type !== 'List' ? details.split(',').map(s=>s.trim()).filter(Boolean) : [],
itemType: null, itemFields: []
};
}).filter(f => f.name);
currentSubFieldTargetInput.value = JSON.stringify(fields);
currentSubFieldTargetInput.dispatchEvent(new Event('input'));
currentSubFieldTargetInput.dispatchEvent(new Event('change'));
subFieldEditorModalInstance.hide();
}
let fieldEditorTargetId = null;
let fieldEditorModalInstance = null;
@@ -1733,8 +1903,16 @@
nameInput.dataset.field = 'name';
const descriptionInput = makeInput(field.description, 'e.g. Employee ID');
descriptionInput.dataset.field = 'description';
const exampleInput = makeInput(field.example, 'e.g. EMP10001');
const patternInput = makeInput(field.pattern, 'e.g. ^[0-9]{4}$');
patternInput.dataset.field = 'pattern';
const exampleInput = document.createElement('textarea');
exampleInput.className = 'form-control form-control-sm';
exampleInput.placeholder = 'e.g. EMP10001\nEMP10002';
exampleInput.rows = 2;
exampleInput.dataset.field = 'example';
exampleInput.value = (field.examples || []).join('\n') || '';
const typeSelect = document.createElement('select');
typeSelect.className = 'form-select form-select-sm';
@@ -1752,13 +1930,13 @@
input.add(new Option(itemType, itemType, false, (value || 'String') === itemType));
});
} else {
input = makeInput(type === 'Enum' ? value : '', 'Enum: OPEN, CLOSED');
input = makeInput(value, 'Enum csv (콤마 구분)');
}
input.dataset.field = 'details';
return input;
};
let detailsInput = createDetailsInput(field.type || 'String',
field.type === 'Enum' ? (field.enumValues || []).join(', ') : field.itemType);
field.type !== 'List' ? (field.enumValues || []).join(', ') : field.itemType);
const requiredSelect = document.createElement('select');
requiredSelect.className = 'form-select form-select-sm';
@@ -1766,14 +1944,32 @@
requiredSelect.add(new Option('Required', 'true', false, field.required === true || field.required === 'true'));
requiredSelect.add(new Option('Optional', 'false', false, !(field.required === true || field.required === 'true')));
const itemFieldsContainer = document.createElement('div');
itemFieldsContainer.className = 'd-flex gap-1';
const itemFieldsInput = makeInput(field.type === 'List' && field.itemType === 'Object'
? JSON.stringify(field.itemFields || []) : '', 'Object fields JSON e.g. [{"name":"date","type":"String"}]');
? JSON.stringify(field.itemFields || []) : '', 'JSON 직접입력 또는 우측 편집 클릭');
itemFieldsInput.dataset.field = 'itemFields';
itemFieldsInput.style.flex = '1';
const editItemBtn = document.createElement('button');
editItemBtn.type = 'button';
editItemBtn.className = 'btn-secondary-action';
editItemBtn.textContent = '편집';
editItemBtn.style.padding = '0.2rem 0.5rem';
editItemBtn.onclick = () => {
if (typeof openSubFieldEditor === 'function') openSubFieldEditor(itemFieldsInput);
};
itemFieldsContainer.appendChild(itemFieldsInput);
itemFieldsContainer.appendChild(editItemBtn);
const updateObjectListFieldsState = () => {
const objectList = typeSelect.value === 'List' && detailsInput.value === 'Object';
itemFieldsInput.disabled = !objectList;
editItemBtn.disabled = !objectList;
itemFieldsInput.placeholder = objectList
? 'Object fields JSON e.g. [{"name":"date","type":"String"}]'
? 'JSON 직접입력 또는 우측 편집 클릭'
: 'Select List > Object to enter item fields';
if (!objectList) itemFieldsInput.value = '';
};
@@ -1795,12 +1991,12 @@
});
bindDetailsInput();
updateObjectListFieldsState();
[nameInput, descriptionInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
[nameInput, descriptionInput, patternInput, exampleInput, requiredSelect, itemFieldsInput].forEach(control => {
control.addEventListener('input', updateFieldEditorPreview);
control.addEventListener('change', updateFieldEditorPreview);
});
[nameInput, typeSelect, detailsInput, itemFieldsInput, descriptionInput, exampleInput, requiredSelect].forEach(control => {
[nameInput, typeSelect, detailsInput, itemFieldsContainer, descriptionInput, patternInput, exampleInput, requiredSelect].forEach(control => {
const cell = document.createElement('td');
cell.appendChild(control);
row.appendChild(cell);
@@ -1828,16 +2024,20 @@
let itemFields = [];
if (type === 'List' && details === 'Object') {
const itemFieldsText = row.querySelector('[data-field="itemFields"]').value.trim();
if (!itemFieldsText) throw new Error('List Object는 항목 필드를 입력해야 합니다.');
itemFields = JSON.parse(itemFieldsText);
if (!Array.isArray(itemFields) || itemFields.length === 0) throw new Error('List Object 항목 필드는 JSON 배열이어야 합니다.');
try {
itemFields = itemFieldsText ? JSON.parse(itemFieldsText) : [];
if (!Array.isArray(itemFields)) itemFields = [];
} catch (e) {
itemFields = [];
}
}
return {
name: row.querySelector('[data-field="name"]').value.trim(), type,
description: row.querySelector('[data-field="description"]').value.trim(),
example: row.querySelector('[data-field="example"]').value.trim(),
pattern: row.querySelector('[data-field="pattern"]').value.trim(),
examples: row.querySelector('[data-field="example"]').value.split('\n').map(e => e.trim()).filter(Boolean),
required: row.querySelector('[data-field="required"]').value === 'true',
enumValues: type === 'Enum' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
enumValues: type !== 'List' ? details.split(',').map(value => value.trim()).filter(Boolean) : [],
itemType: type === 'List' ? (details || 'String') : null,
itemFields
};
@@ -1920,7 +2120,10 @@
form.elements.baseName.value = result.baseName || '';
form.elements.title.value = result.title || '';
form.elements.description.value = result.description || '';
form.elements.categoryKey.value = result.categoryKey || '';
if (!form.elements.categoryKey.value) {
form.elements.categoryKey.value = result.categoryKey || '';
if (typeof loadUseCasesForSelection === 'function') loadUseCasesForSelection();
}
form.elements.routingType.value = result.routingType || 'HTTP';
form.elements.httpApiName.value = result.httpApiName || '';
form.elements.functionDescription.value = result.functionDescription || '';

View File

@@ -72,6 +72,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center">
@@ -178,7 +179,7 @@
}
const grouped = {};
tools.forEach(tool => {
const group = tool.categoryKey || 'Others';
const group = tool._meta?.category_key || 'Others';
if (!grouped[group]) grouped[group] = [];
grouped[group].push(tool);
});
@@ -208,7 +209,7 @@
const card = document.createElement('div');
card.className = 'tool-card';
const isDirect = tool.integrationType === 'DIRECT';
const isDirect = tool._meta?.integrationType === 'DIRECT';
const badgeBg = isDirect ? 'rgba(59,130,246,0.15)' : 'rgba(249,115,22,0.15)';
const badgeColor = isDirect ? '#60a5fa' : '#fb923c';
const badgeBorder = isDirect ? 'rgba(59,130,246,0.3)' : 'rgba(249,115,22,0.3)';
@@ -254,14 +255,14 @@
<div style="display:flex; align-items:flex-start; justify-content:space-between;">
<div style="flex:1;">
<div style="display:flex; align-items:center; margin-bottom:4px;">
<h3 style="font-size:16px; font-weight:600; color:#f4f4f5;">${tool.displayName || tool.name}</h3>
<h3 style="font-size:16px; font-weight:600; color:#f4f4f5;">${tool.title || tool.name}</h3>
<span style="margin:0 8px; color:#3f3f46;">/</span>
<span class="geist-mono" style="font-size:12px; color:#71717a;">${tool.name}</span>
</div>
<p style="font-size:14px; color:#a1a1aa;">${tool.description || 'No description provided.'}</p>
</div>
<div style="margin-left:16px; padding:4px 10px; border-radius:6px; font-size:11px; font-weight:600; background:${badgeBg}; color:${badgeColor}; border:1px solid ${badgeBorder};" class="geist-mono">
${tool.integrationType || 'DIRECT'}
${tool._meta?.integrationType || 'DIRECT'}
</div>
</div>
${paramHtml}

View File

@@ -59,6 +59,7 @@
<a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center">

View File

@@ -103,6 +103,7 @@
<a href="/chat.html" class="text-zinc-400 hover:text-white transition-colors">Chat</a>
<a href="/tester.html" class="text-zinc-400 hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" class="text-zinc-400 hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center">

View File

@@ -114,6 +114,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center space-x-3">
@@ -205,7 +206,7 @@
const tools = data.result?.tools || [];
const groupedTools = {};
tools.forEach(tool => {
const group = tool.categoryKey || 'Others';
const group = tool._meta?.category_key || 'Others';
if (!groupedTools[group]) groupedTools[group] = [];
groupedTools[group].push(tool);
});
@@ -218,7 +219,7 @@
option.textContent = tool.name;
option.dataset.schema = JSON.stringify(tool.parametersSchema);
option.dataset.prompts = JSON.stringify(tool.actionPrompts || {});
option.dataset.integrationType = tool.integrationType || 'HTTP';
option.dataset.integrationType = tool._meta?.integrationType || 'HTTP';
optgroup.appendChild(option);
if (tool.actionPrompts) Object.assign(actionPrompts, tool.actionPrompts);
});

View File

@@ -331,7 +331,7 @@
const groupedTools = {};
tools.forEach(tool => {
const group = tool.domainGroup || '기타 그룹';
const group = tool._meta?.category_key || '기타';
if (!groupedTools[group]) {
groupedTools[group] = [];
}
@@ -348,10 +348,10 @@
groupedTools[group].forEach(tool => {
const option = document.createElement('option');
option.value = tool.toolName;
const commType = tool.integrationType ? tool.integrationType : 'HTTP';
option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.toolName})` : `[${commType}] ${tool.toolName}`;
option.dataset.schema = JSON.stringify(tool.parametersSchema);
option.value = tool.name;
const commType = tool._meta?.integrationType || 'HTTP';
option.textContent = tool.description ? `[${commType}] ${tool.description} (${tool.name})` : `[${commType}] ${tool.name}`;
option.dataset.schema = JSON.stringify(tool.inputSchema || tool.parametersSchema);
option.dataset.prompts = JSON.stringify(tool.actionPrompts || {});
optgroup.appendChild(option);
@@ -369,7 +369,7 @@
});
}
toolFunctions[tool.toolName] = enums.map(e => {
toolFunctions[tool.name] = enums.map(e => {
return { value: e, label: descMap[e] || e };
});

View File

@@ -77,6 +77,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center">
@@ -111,6 +112,9 @@
<div class="flex gap-4 mb-6 mt-6">
<input type="text" id="searchInput" placeholder="Search by tool name..." class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 outline-none" oninput="applyFilters()">
<select id="moduleFilter" class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-48 p-2.5 outline-none" onchange="applyFilters()">
<option value="">All Modules</option>
</select>
<select id="categoryFilter" class="bg-[#18181b] border border-[#3f3f46] text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-48 p-2.5 outline-none" onchange="applyFilters()">
<option value="">All Categories</option>
</select>
@@ -150,6 +154,7 @@
<table class="table" id="toolsTable">
<thead>
<tr>
<th style="width:10%">Module</th>
<th style="width:10%">Category</th>
<th style="width:25%">Tool Name</th>
<th style="width:25%">Payload</th>
@@ -284,26 +289,35 @@
const savedPayloads = JSON.parse(localStorage.getItem('mcp_tester_payloads') || '{}');
const categories = new Set();
const modules = new Set();
allTools.forEach(t => {
t._customPayload = savedPayloads[t.name] || null;
t._lastStatus = 'NOT RUN';
t._isFailed = false;
t._latency = 0;
t._responseData = null;
categories.add(t.categoryKey || 'oth');
categories.add(t._meta?.category_key || 'oth');
const modName = t.name ? t.name.split('_')[0].replace('dap-was-', '') : '';
if (modName) {
modules.add(modName);
}
});
const catSelect = document.getElementById('categoryFilter');
catSelect.innerHTML = '<option value="">All Categories</option>';
Array.from(categories).sort().forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat.toUpperCase();
catSelect.appendChild(opt);
catSelect.innerHTML += `<option value="${cat}">${cat.toUpperCase()}</option>`;
});
const modSelect = document.getElementById('moduleFilter');
modSelect.innerHTML = '<option value="">All Modules</option>';
Array.from(modules).sort().forEach(mod => {
modSelect.innerHTML += `<option value="${mod}">${mod}</option>`;
});
applyFilters();
} catch (error) {
document.getElementById('toolsTbody').innerHTML = '<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools.</td></tr>';
document.getElementById('toolsTbody').innerHTML = '<tr><td colspan="6" class="text-center text-red-400 py-8">Failed to load tools.</td></tr>';
}
}
@@ -340,11 +354,14 @@
function applyFilters() {
const search = document.getElementById('searchInput').value.toLowerCase();
const category = document.getElementById('categoryFilter').value;
const moduleFlt = document.getElementById('moduleFilter').value;
filteredTools = allTools.filter(t => {
const matchSearch = t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search);
const matchCategory = !category || (t.categoryKey || 'oth') === category;
return matchSearch && matchCategory;
const matchCategory = !category || (t._meta?.category_key || 'oth') === category;
const toolMod = t.name ? t.name.split('_')[0].replace('dap-was-', '') : '';
const matchModule = !moduleFlt || toolMod === moduleFlt;
return matchSearch && matchCategory && matchModule;
});
document.getElementById('visibleCount').textContent = `(${filteredTools.length} tools)`;
@@ -355,7 +372,7 @@
function renderTable() {
const tbody = document.getElementById('toolsTbody');
if (filteredTools.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools match the filter.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-zinc-500 py-8">No tools match the filter.</td></tr>';
return;
}
@@ -379,7 +396,8 @@
html += `
<tr id="row-${index}">
<td><span class="cat-badge">${tool.categoryKey || 'oth'}</span></td>
<td><span class="cat-badge" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">${tool.name ? tool.name.split('_')[0].replace('dap-was-', '') : 'unknown'}</span></td>
<td><span class="cat-badge">${tool._meta?.category_key || 'oth'}</span></td>
<td class="font-medium text-slate-200">${tool.name}</td>
<td class="text-sm text-zinc-400 geist-mono flex items-center gap-3">
<span title='${payloadStr.replace(/'/g, "&apos;")}'>${shortPayload}</span>

View File

@@ -0,0 +1,199 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AXHUB Tool Report</title>
<style>
:root {
color-scheme: dark;
font-family: "Geist", "Pretendard", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #09090b;
color: #f4f4f5;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #09090b; color: #f4f4f5; overflow-y: scroll; }
a { color: inherit; text-decoration: none; }
header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #27272a; background: rgba(9,9,11,.86); backdrop-filter: blur(16px); }
.nav-shell { max-width: 1152px; height: 56px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; }
.nav-left, nav { display: flex; align-items: center; }
.brand { display: flex; align-items: center; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
.brand-dot { width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,.8); }
.divider { width: 1px; height: 16px; margin: 0 20px; background: #27272a; }
nav { gap: 20px; font-size: 13px; font-weight: 500; color: #a1a1aa; }
nav a { transition: color .15s ease; white-space: nowrap; }
nav a:hover { color: #fff; }
nav .active { color: #fff; font-weight: 600; }
.version { padding: 4px 8px; border: 1px solid rgba(59,130,246,.2); border-radius: 5px; background: rgba(59,130,246,.1); color: #60a5fa; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
main { max-width: 1152px; margin: 0 auto; padding: 40px 24px 72px; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1 { margin: 0 0 10px; font-size: 30px; line-height: 1.2; letter-spacing: -.035em; }
.subtitle { margin: 0; color: #a1a1aa; font-size: 14px; line-height: 1.65; }
.count-badge { padding: 7px 11px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; color: #a1a1aa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; white-space: nowrap; }
.panel { overflow: hidden; border: 1px solid #27272a; border-radius: 12px; background: #111113; box-shadow: 0 18px 50px rgba(0,0,0,.22); }
.toolbar { display: grid; grid-template-columns: minmax(260px,1fr) 190px auto auto; gap: 10px; padding: 16px; border-bottom: 1px solid #27272a; background: #111113; }
input, select, button { min-height: 40px; border: 1px solid #3f3f46; border-radius: 7px; background: #18181b; color: #e4e4e7; padding: 8px 12px; font: inherit; font-size: 13px; outline: none; }
input::placeholder { color: #71717a; }
input:focus, select:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
button { cursor: pointer; font-weight: 600; transition: border-color .15s, background .15s, color .15s; }
button:hover { border-color: #52525b; background: #27272a; color: #fff; }
button.primary { min-width: 174px; border-color: #2563eb; background: #2563eb; color: #fff; }
button.primary:hover { background: #1d4ed8; }
button:disabled { cursor: not-allowed; opacity: .45; }
.table-wrap { max-height: 570px; overflow: auto; }
table { width: 100%; min-width: 980px; border-collapse: collapse; }
th, td { padding: 13px 15px; border-bottom: 1px solid #27272a; text-align: left; vertical-align: top; }
th { position: sticky; top: 0; z-index: 2; background: #18181b; color: #a1a1aa; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
td { color: #d4d4d8; font-size: 13px; line-height: 1.55; }
tbody tr { transition: background .12s ease; }
tbody tr:hover { background: rgba(39,39,42,.5); }
td:first-child, th:first-child { width: 58px; text-align: center; }
input[type="checkbox"] { width: 16px; height: 16px; min-height: 0; accent-color: #3b82f6; }
code { color: #60a5fa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; }
.muted { color: #71717a; }
.panel-footer { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; border-top: 1px solid #27272a; background: #111113; }
#message { color: #a1a1aa; font-size: 13px; }
#message.error { color: #fca5a5; }
.loading-row { padding: 42px !important; color: #71717a; text-align: center !important; }
@media (max-width: 900px) {
.nav-shell { overflow-x: auto; }
.version { display: none; }
nav { gap: 14px; }
.toolbar { grid-template-columns: 1fr 1fr; }
.page-heading { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header>
<div class="nav-shell">
<div class="nav-left">
<a id="gatewayHome" class="brand" href="#"><span class="brand-dot"></span><span>AXHUB Gateway</span></a>
<div class="divider"></div>
<nav>
<a data-gateway-path="/admin/scaffold.html" href="#">Scaffold</a>
<a data-gateway-path="/catalog.html" href="#">Catalog</a>
<a data-gateway-path="/playground.html" href="#">Playground</a>
<a data-gateway-path="/chat.html" href="#">Chat</a>
<a data-gateway-path="/tester.html" href="#">Tester</a>
<a data-gateway-path="/tool-test-console.html" href="#">Console</a>
<a class="active" href="/tool-report.html">Report</a>
</nav>
</div>
<span class="version">v0.0.1</span>
</div>
</header>
<main>
<div class="page-heading">
<div>
<h1>Tool Report</h1>
<p class="subtitle">보고서에 포함할 MCP 툴을 선택하고, 소스 어노테이션과 전문 필드를 동일한 Excel 양식으로 생성합니다.</p>
</div>
<span id="summaryBadge" class="count-badge">Loading tools...</span>
</div>
<section class="panel">
<div class="toolbar">
<input id="keyword" type="search" autocomplete="off" placeholder="툴명, 제목 또는 설명 검색">
<select id="category"><option value="">전체 카테고리</option></select>
<button id="selectAll" type="button">전체 선택</button>
<button id="clearAll" type="button">전체 해제</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>선택</th><th>툴명</th><th>제목</th><th>카테고리</th><th>연계 ID</th><th>설명</th></tr></thead>
<tbody id="tools"><tr><td class="loading-row" colspan="6">툴 목록을 불러오는 중입니다.</td></tr></tbody>
</table>
</div>
<div class="panel-footer">
<span id="message">소스 인덱스를 준비하고 있습니다.</span>
<button id="download" type="button" class="primary" disabled>Excel 보고서 생성</button>
</div>
</section>
</main>
<script>
const state = { tools: [], selected: new Set() };
const el = id => document.getElementById(id);
const gateway = location.origin;
el('gatewayHome').href = gateway + '/index.html';
document.querySelectorAll('[data-gateway-path]').forEach(link => link.href = gateway + link.dataset.gatewayPath);
function filtered() {
const keyword = el('keyword').value.trim().toLowerCase();
const category = el('category').value;
return state.tools.filter(tool => (!category || tool.categoryKey === category) &&
(!keyword || [tool.name, tool.title, tool.description].join(' ').toLowerCase().includes(keyword)));
}
function render() {
const visible = filtered();
el('tools').innerHTML = visible.length ? visible.map(tool => `<tr>
<td><input type="checkbox" aria-label="${escapeHtml(tool.name)} 선택" data-name="${escapeHtml(tool.name)}" ${state.selected.has(tool.name) ? 'checked' : ''}></td>
<td><code>${escapeHtml(tool.name)}</code></td><td>${escapeHtml(tool.title)}</td>
<td>${escapeHtml(tool.categoryKey)}</td><td>${escapeHtml(tool.mappingId || '-')}</td><td class="muted">${escapeHtml(tool.description)}</td>
</tr>`).join('') : '<tr><td class="loading-row" colspan="6">조건에 맞는 툴이 없습니다.</td></tr>';
el('tools').querySelectorAll('input[type=checkbox]').forEach(box => box.addEventListener('change', event => {
event.target.checked ? state.selected.add(event.target.dataset.name) : state.selected.delete(event.target.dataset.name);
updateStatus();
}));
updateStatus();
}
function updateStatus(text) {
el('message').textContent = text || `전체 ${state.tools.length}개 중 ${state.selected.size}개 선택`;
el('message').classList.remove('error');
el('summaryBadge').textContent = `${state.tools.length} tools · ${state.selected.size} selected`;
el('download').disabled = state.selected.size === 0;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
}
async function load() {
try {
const response = await fetch('/report/api/report-tools');
if (!response.ok) throw new Error('툴 목록 조회에 실패했습니다.');
state.tools = await response.json();
[...new Set(state.tools.map(tool => tool.categoryKey))].sort().forEach(category => {
const option = document.createElement('option');
option.value = category; option.textContent = category; el('category').appendChild(option);
});
render();
} catch (error) {
el('tools').innerHTML = '<tr><td class="loading-row" colspan="6">툴 목록을 표시할 수 없습니다.</td></tr>';
el('message').textContent = error.message; el('message').classList.add('error');
el('summaryBadge').textContent = 'Load failed';
}
}
el('keyword').addEventListener('input', render);
el('category').addEventListener('change', render);
el('selectAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.add(tool.name)); render(); });
el('clearAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.delete(tool.name)); render(); });
el('download').addEventListener('click', async () => {
el('download').disabled = true; updateStatus('소스를 분석하고 Excel을 생성하는 중입니다.');
try {
const response = await fetch('/report/api/tool-reports/excel', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({toolNames: [...state.selected]})
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || '보고서 생성에 실패했습니다.');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = url; anchor.download = 'tool-report.xlsx'; anchor.click(); URL.revokeObjectURL(url);
updateStatus(`선택한 ${state.selected.size}개 툴의 보고서를 생성했습니다.`);
} catch (error) {
el('message').textContent = error.message; el('message').classList.add('error');
} finally { el('download').disabled = state.selected.size === 0; }
});
load();
</script>
</body>
</html>

View File

@@ -1,42 +0,0 @@
package io.shinhanlife.dap.mcg;
/**
* @package io.shinhanlife.dap.mcg
* @className DapGatewayApplicationTests
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import io.shinhanlife.dap.mcg.audit.AuditLogService;
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
@SpringBootTest
@ActiveProfiles("test")
class DapGatewayApplicationTests {
// Mock components that might require external dependencies (like Redis/DB) to pass the context load
@MockitoBean
private RedisToolTraceService redisToolTraceService;
@MockitoBean
private AuditLogService auditLogService;
@Test
void contextLoads() {
// This test ensures that all Spring beans, @ConfigurationProperties, and dependencies are correctly wired.
// It validates that the ported classes (ExecuteService, AgentResponseBudgetService, LargeToolResponseService, etc.)
// have no @Autowired or initialization errors.
}
}

View File

@@ -0,0 +1,11 @@
package io.shinhanlife.dap.mcg;
import org.junit.jupiter.api.Test;
class DapGatewayApplicationTests {
@Test
void gatewayTestSourceIsCompilable() {
// Spring context integration tests are covered by feature-specific tests.
}
}

View File

@@ -46,8 +46,8 @@ class DocumentGeneratorServiceTest {
.name("oth.cmm.customer.detail")
.description("테스트 고객의 상세정보를 조회합니다.")
.categoryKey("cmm")
.endpoint("http://was-oth:8084/mcp/oth.cmm.customer.detail")
.podUrl("http://was-oth:8084")
.endpoint("http://was-cus:8084/mcp/oth.cmm.customer.detail")
.podUrl("http://was-cus:8084")
.integrationType("REST")
.mciServiceId("CUST_001")
.operationType(OperationType.READ)
@@ -108,7 +108,7 @@ class DocumentGeneratorServiceTest {
assertThat(workbook.getSheet("Request In").getRow(1).getCell(2).getStringCellValue())
.isEqualTo("테스트 고객조회 Tool");
assertThat(workbook.getSheet("Request In").getRow(3).getCell(3).getStringCellValue())
.isEqualTo("http://was-oth:8084/mcp/oth.cmm.customer.detail");
.isEqualTo("http://was-cus:8084/mcp/oth.cmm.customer.detail");
assertThat(workbook.getSheet("Request In").getRow(8).getCell(5).getStringCellValue())
.isEqualTo("customerId");
assertThat(workbook.getSheet("Request In").getRow(8).getCell(7).getStringCellValue())

View File

@@ -0,0 +1,14 @@
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /workspace
COPY . .
RUN chmod +x gradlew \
&& ./gradlew :dap-tool-report:bootJar -x test --no-daemon \
&& find dap-tool-report/build/libs -name '*-SNAPSHOT.jar' ! -name '*-plain.jar' -exec cp {} /workspace/app.jar \;
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
COPY --from=builder /workspace/app.jar app.jar
EXPOSE 8092
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,16 @@
plugins {
id 'org.springframework.boot'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.github.javaparser:javaparser-core:3.26.3'
implementation 'org.apache.poi:poi-ooxml:5.3.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.dap.report;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @package io.shinhanlife.dap.report
* @className ToolReportApplication
* @description 툴 소스를 읽기 전용으로 분석하여 Excel 보고서를 생성하는 독립 애플리케이션
* @author 0986406
* @create 2026.08.07
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.07 0986406 최초생성
* </pre>
*/
@SpringBootApplication
public class ToolReportApplication {
public static void main(String[] args) {
SpringApplication.run(ToolReportApplication.class, args);
}
}

View File

@@ -0,0 +1,48 @@
package io.shinhanlife.dap.report.application;
import io.shinhanlife.dap.report.excel.ToolReportExcelWriter;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import io.shinhanlife.dap.report.source.ToolDetailAnalyzer;
import io.shinhanlife.dap.report.source.ToolSourceDiscovery;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/** Gateway가 선택해 전달한 툴의 원천 분석과 Excel 출력을 담당한다. */
@Service
public class ToolReportService {
private final ToolSourceDiscovery discovery;
private final ToolDetailAnalyzer analyzer;
private final ToolReportExcelWriter excelWriter;
public ToolReportService(ToolSourceDiscovery discovery,
ToolDetailAnalyzer analyzer,
ToolReportExcelWriter excelWriter) {
this.discovery = discovery;
this.analyzer = analyzer;
this.excelWriter = excelWriter;
}
public byte[] createExcel(List<String> selectedNames) {
if (selectedNames == null || selectedNames.isEmpty()) {
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
}
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
List<String> missingSources = selectedNames.stream()
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
if (!missingSources.isEmpty()) {
throw new IllegalArgumentException("분석할 원천 소스가 없는 툴입니다: " + missingSources);
}
List<ToolReportModel> reports = selectedNames.stream().distinct()
.map(sourceTools::get)
.map(analyzer::analyze)
.toList();
return excelWriter.write(reports);
}
}

View File

@@ -0,0 +1,10 @@
package io.shinhanlife.dap.report.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/** 보고서 모듈 설정. */
@Configuration
@EnableConfigurationProperties(ReportProperties.class)
public class ReportConfiguration {
}

View File

@@ -0,0 +1,8 @@
package io.shinhanlife.dap.report.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** 보고서 소스 위치와 출력 정책 설정. */
@ConfigurationProperties(prefix = "report")
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
}

View File

@@ -0,0 +1,255 @@
package io.shinhanlife.dap.report.excel;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Component;
/** 모든 보고서 시트에 동일한 전문 I/O 문서 디자인을 적용한다. */
@Component
public class ToolReportExcelWriter {
private static final int HEADER_ROW = 7;
private static final int FIRST_DATA_ROW = 8;
public byte[] write(List<ToolReportModel> reports) {
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
Styles styles = new Styles(workbook);
writeSummary(workbook, reports, styles);
writeFields(workbook, reports, styles);
writeDiagnostics(workbook, reports, styles);
workbook.write(output);
return output.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("Excel 보고서 생성에 실패했습니다.", exception);
}
}
private void writeSummary(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "제목", "설명", "카테고리", "연계 ID", "등록", "승인 필요",
"Read Only", "Destructive", "Idempotent", "Open World", "Request", "Response", "UseCase", "원천 파일"};
Sheet sheet = workbook.createSheet("툴 기본정보");
decorateSheet(sheet, "MCP Tool Catalog Report", "선택 툴 기본정보", reports, headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
ToolSummary tool = report.tool();
Row row = sheet.createRow(rowIndex++);
Object[] values = {sequence++, tool.name(), tool.title(), tool.description(), tool.categoryKey(),
tool.mappingId(), yn(tool.register()), yn(tool.requiresApproval()), yn(tool.readOnlyHint()),
yn(tool.destructiveHint()), yn(tool.idempotentHint()), yn(tool.openWorldHint()),
tool.requestType(), tool.responseType(), tool.useCaseClass(), tool.sourceFile()};
values(row, values, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
}
finishTable(sheet, rowIndex, headers.length,
new int[]{7, 30, 24, 45, 12, 18, 10, 12, 12, 12, 12, 12, 20, 20, 25, 55});
}
private void writeFields(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "원천 종류", "방향", "소유 타입", "필드명", "데이터 타입",
"필수", "설명", "제약조건 / 어노테이션", "원천 파일"};
Sheet sheet = workbook.createSheet("수집 필드");
decorateSheet(sheet, "Tool Source I/O Report", "어노테이션 · DTO · 전문 필드", reports, headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
for (FieldDefinition field : report.fields()) {
Row row = sheet.createRow(rowIndex++);
Object[] values = {sequence++, field.toolName(), field.sourceKind(), field.direction(),
field.ownerType(), field.fieldName(), field.dataType(),
field.required() == null ? "" : yn(field.required()), field.description(),
field.constraints(), field.sourceFile()};
values(row, values, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
row.getCell(2).setCellStyle(styles.bodyCenter);
row.getCell(3).setCellStyle(styles.bodyCenter);
row.getCell(7).setCellStyle(styles.bodyCenter);
}
}
finishTable(sheet, rowIndex, headers.length,
new int[]{7, 30, 16, 11, 24, 24, 18, 9, 45, 55, 60});
}
private void writeDiagnostics(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "수준", "진단 내용"};
Sheet sheet = workbook.createSheet("분석 결과");
decorateSheet(sheet, "Tool Analysis Report", "소스 분석 및 정합성 진단", reports, headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
if (report.diagnostics().isEmpty()) {
Row row = sheet.createRow(rowIndex++);
values(row, new Object[]{sequence++, report.tool().name(), "정상", "분석 경고 없음"}, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
row.getCell(2).setCellStyle(styles.bodyCenter);
} else {
for (String diagnostic : report.diagnostics()) {
Row row = sheet.createRow(rowIndex++);
values(row, new Object[]{sequence++, report.tool().name(), "경고", diagnostic}, styles.warning);
row.getCell(0).setCellStyle(styles.warningCenter);
row.getCell(2).setCellStyle(styles.warningCenter);
}
}
}
finishTable(sheet, rowIndex, headers.length, new int[]{7, 30, 12, 80});
}
private void decorateSheet(Sheet sheet, String titleText, String reportType,
List<ToolReportModel> reports, int columnCount, Styles styles) {
sheet.setDisplayGridlines(false);
sheet.setAutobreaks(true);
sheet.getPrintSetup().setLandscape(true);
sheet.getPrintSetup().setFitWidth((short) 1);
sheet.getPrintSetup().setFitHeight((short) 0);
sheet.setFitToPage(true);
Row title = sheet.createRow(0);
title.setHeightInPoints(24);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, columnCount - 1));
Cell titleCell = title.createCell(0);
titleCell.setCellValue(titleText);
titleCell.setCellStyle(styles.title);
metadataRow(sheet, 2, "보고서 구분", reportType, columnCount, styles);
metadataRow(sheet, 3, "선택 툴 수", reports.size(), columnCount, styles);
metadataRow(sheet, 4, "생성 일시",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), columnCount, styles);
}
private void metadataRow(Sheet sheet, int rowIndex, String label, Object value,
int columnCount, Styles styles) {
Row row = sheet.createRow(rowIndex);
row.setHeightInPoints(19);
int labelEnd = Math.min(1, columnCount - 1);
if (labelEnd > 0) sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, labelEnd));
Cell labelCell = row.createCell(0);
labelCell.setCellValue(label);
labelCell.setCellStyle(styles.metaLabel);
int valueStart = labelEnd + 1;
if (valueStart < columnCount - 1) {
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, valueStart, columnCount - 1));
}
Cell valueCell = row.createCell(valueStart);
if (value instanceof Number number) valueCell.setCellValue(number.doubleValue());
else valueCell.setCellValue(value == null ? "" : String.valueOf(value));
valueCell.setCellStyle(styles.metaValue);
}
private void header(Sheet sheet, String[] headers, Styles styles) {
Row row = sheet.createRow(HEADER_ROW);
row.setHeightInPoints(30);
for (int column = 0; column < headers.length; column++) {
Cell cell = row.createCell(column);
cell.setCellValue(headers[column]);
cell.setCellStyle(styles.header);
}
}
private void finishTable(Sheet sheet, int rowIndex, int columnCount, int[] characterWidths) {
widths(sheet, characterWidths);
sheet.createFreezePane(0, FIRST_DATA_ROW);
sheet.setAutoFilter(new CellRangeAddress(HEADER_ROW, Math.max(HEADER_ROW, rowIndex - 1), 0, columnCount - 1));
sheet.setRepeatingRows(new CellRangeAddress(HEADER_ROW, HEADER_ROW, -1, -1));
}
private void values(Row row, Object[] values, CellStyle style) {
row.setHeightInPoints(19);
for (int column = 0; column < values.length; column++) {
Cell cell = row.createCell(column);
Object value = values[column];
if (value instanceof Number number) cell.setCellValue(number.doubleValue());
else cell.setCellValue(value == null ? "" : String.valueOf(value));
cell.setCellStyle(style);
}
}
private void widths(Sheet sheet, int[] characterWidths) {
for (int i = 0; i < characterWidths.length; i++) {
sheet.setColumnWidth(i, Math.min(255, characterWidths[i]) * 256);
}
}
private String yn(boolean value) {
return value ? "Y" : "N";
}
private static final class Styles {
private final CellStyle title;
private final CellStyle metaLabel;
private final CellStyle metaValue;
private final CellStyle header;
private final CellStyle body;
private final CellStyle bodyCenter;
private final CellStyle warning;
private final CellStyle warningCenter;
private Styles(Workbook workbook) {
title = style(workbook, "#262626", "#FFFFFF", 12, true, HorizontalAlignment.CENTER, false);
metaLabel = style(workbook, "#333333", "#FFFFFF", 10, true, HorizontalAlignment.CENTER, false);
metaValue = style(workbook, "#FFFFFF", "#222222", 10, false, HorizontalAlignment.LEFT, false);
header = style(workbook, "#404040", "#FFFFFF", 9, true, HorizontalAlignment.CENTER, true);
body = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.LEFT, true);
bodyCenter = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.CENTER, true);
warning = style(workbook, "#FFF2CC", "#7F6000", 9, false, HorizontalAlignment.LEFT, true);
warningCenter = style(workbook, "#FFF2CC", "#7F6000", 9, true, HorizontalAlignment.CENTER, true);
}
private CellStyle style(Workbook workbook, String fill, String fontColor, int size, boolean bold,
HorizontalAlignment alignment, boolean borders) {
XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
style.setFillForegroundColor(color(fill));
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(alignment);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(true);
Font font = workbook.createFont();
font.setFontName("Carlito");
font.setFontHeightInPoints((short) size);
font.setBold(bold);
((org.apache.poi.xssf.usermodel.XSSFFont) font).setColor(color(fontColor));
style.setFont(font);
if (borders) applyBorders(style);
return style;
}
private void applyBorders(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
short borderColor = org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex();
style.setTopBorderColor(borderColor);
style.setBottomBorderColor(borderColor);
style.setLeftBorderColor(borderColor);
style.setRightBorderColor(borderColor);
}
private XSSFColor color(String hex) {
return new XSSFColor(Color.decode(hex), new DefaultIndexedColorMap());
}
}
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.report.model;
/** DTO, JSON Schema 또는 원천 전문에서 수집한 필드 정의. */
public record FieldDefinition(
String toolName,
String sourceKind,
String direction,
String ownerType,
String fieldName,
String dataType,
Boolean required,
String description,
String constraints,
String sourceFile) {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.dap.report.model;
import java.util.List;
/** 수집 단계와 Excel 출력 단계를 분리하는 표준 중간 모델. */
public record ToolReportModel(ToolSummary tool, List<FieldDefinition> fields, List<String> diagnostics) {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.dap.report.model;
import java.util.List;
/** 사용자가 선택한 툴 보고서 생성 요청. */
public record ToolReportRequest(List<String> toolNames) {
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.report.model;
/** UI 선택 목록과 보고서 기본정보에 사용하는 툴 요약. */
public record ToolSummary(
String name,
String title,
String description,
String categoryKey,
String mappingId,
boolean register,
boolean requiresApproval,
boolean readOnlyHint,
boolean destructiveHint,
boolean idempotentHint,
boolean openWorldHint,
String requestType,
String responseType,
String useCaseClass,
String sourceFile,
String inputSchemaResource,
String outputSchemaResource) {
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.report.presentation;
import io.shinhanlife.dap.report.application.ToolReportService;
import io.shinhanlife.dap.report.config.ReportProperties;
import io.shinhanlife.dap.report.model.ToolReportRequest;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 툴 선택 목록과 Excel 다운로드 API. */
@RestController
@RequestMapping("/api")
public class ToolReportController {
private final ToolReportService service;
private final ReportProperties properties;
public ToolReportController(ToolReportService service, ReportProperties properties) {
this.service = service;
this.properties = properties;
}
@PostMapping(value = "/tool-reports/excel",
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> excel(@RequestBody ToolReportRequest request) {
byte[] content = service.createExcel(request.toolNames());
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
? "tool-report" : properties.outputFilenamePrefix();
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
ContentDisposition disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(content.length)
.body(content);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
}
}

View File

@@ -0,0 +1,55 @@
package io.shinhanlife.dap.report.source;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import java.util.Optional;
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
final class JavaAnnotationReader {
private JavaAnnotationReader() {
}
static Optional<AnnotationExpr> find(NodeList<AnnotationExpr> annotations, String simpleName) {
return annotations.stream().filter(a -> a.getName().getIdentifier().equals(simpleName)).findFirst();
}
static Optional<Expression> value(AnnotationExpr annotation, String key) {
if (annotation instanceof NormalAnnotationExpr normal) {
return normal.getPairs().stream()
.filter(pair -> pair.getNameAsString().equals(key))
.map(MemberValuePair::getValue)
.findFirst();
}
if (annotation instanceof SingleMemberAnnotationExpr single && "value".equals(key)) {
return Optional.of(single.getMemberValue());
}
return Optional.empty();
}
static String string(AnnotationExpr annotation, String key, String fallback) {
return value(annotation, key)
.filter(StringLiteralExpr.class::isInstance)
.map(StringLiteralExpr.class::cast)
.map(StringLiteralExpr::asString)
.orElse(fallback);
}
static boolean bool(AnnotationExpr annotation, String key, boolean fallback) {
return value(annotation, key)
.filter(BooleanLiteralExpr.class::isInstance)
.map(BooleanLiteralExpr.class::cast)
.map(BooleanLiteralExpr::getValue)
.orElse(fallback);
}
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
}
}

View File

@@ -0,0 +1,156 @@
package io.shinhanlife.dap.report.source;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** 선택된 툴의 DTO, JSON Schema와 원천 전문 필드를 수집한다. */
@Component
public class ToolDetailAnalyzer {
private final ToolSourceDiscovery discovery;
private final ObjectMapper objectMapper;
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
this.discovery = discovery;
this.objectMapper = new ObjectMapper();
}
public ToolReportModel analyze(ToolSummary tool) {
List<FieldDefinition> fields = new ArrayList<>();
List<String> diagnostics = new ArrayList<>();
Map<String, Path> javaFiles = indexJavaFiles();
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
if (tool.inputSchemaResource().isBlank()) {
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
}
if (tool.outputSchemaResource().isBlank()) {
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
}
if (!tool.mappingId().isBlank()) {
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_I"), "TELEGRAM", "INPUT", fields, diagnostics);
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_O"), "TELEGRAM", "OUTPUT", fields, diagnostics);
}
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
}
private Map<String, Path> indexJavaFiles() {
Map<String, Path> result = new LinkedHashMap<>();
try (var paths = Files.walk(discovery.sourceRoot())) {
paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".java"))
.forEach(path -> result.putIfAbsent(path.getFileName().toString().replace(".java", ""), path));
} catch (IOException exception) {
throw new IllegalStateException("Failed to index Java sources", exception);
}
return result;
}
private void collectSchema(ToolSummary tool, String resource, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (resource == null || resource.isBlank()) return;
String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", "");
List<Path> matches = new ArrayList<>();
try (var paths = Files.walk(discovery.sourceRoot())) {
paths.filter(path -> Files.isRegularFile(path))
.filter(path -> path.toString().replace('\\', '/').endsWith("/src/main/resources/" + relative))
.forEach(matches::add);
} catch (IOException exception) {
diagnostics.add("Schema 검색 실패: " + resource);
return;
}
if (matches.isEmpty()) {
diagnostics.add("Schema를 찾을 수 없음: " + resource);
return;
}
Path path = matches.get(0);
try {
JsonNode root = objectMapper.readTree(path.toFile());
JsonNode properties = root.path("properties");
List<String> required = new ArrayList<>();
root.path("required").forEach(node -> required.add(node.asText()));
Iterator<Map.Entry<String, JsonNode>> iterator = properties.fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> entry = iterator.next();
JsonNode definition = entry.getValue();
fields.add(new FieldDefinition(tool.name(), "JSON_SCHEMA", direction, "",
entry.getKey(), definition.path("type").asText("object"),
required.contains(entry.getKey()), definition.path("description").asText(""),
constraints(definition), relative(path)));
}
} catch (Exception exception) {
diagnostics.add("Schema 분석 실패: " + relative(path));
}
}
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (path == null) {
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
return;
}
try {
CompilationUnit unit = StaticJavaParser.parse(path);
for (TypeDeclaration<?> type : unit.getTypes()) {
collectFields(tool, path, type, sourceKind, direction, fields);
type.getMembers().stream().filter(TypeDeclaration.class::isInstance)
.map(TypeDeclaration.class::cast)
.forEach(nested -> collectFields(tool, path, nested, sourceKind, direction, fields));
}
} catch (Exception exception) {
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
}
}
private void collectFields(ToolSummary tool, Path path, TypeDeclaration<?> owner, String sourceKind,
String direction, List<FieldDefinition> fields) {
for (FieldDeclaration declaration : owner.getFields()) {
AnnotationExpr param = JavaAnnotationReader.find(declaration.getAnnotations(), "McpToolParam").orElse(null);
AnnotationExpr schema = JavaAnnotationReader.find(declaration.getAnnotations(), "Schema").orElse(null);
AnnotationExpr telegram = JavaAnnotationReader.find(declaration.getAnnotations(), "GlowTrgmField").orElse(null);
declaration.getVariables().forEach(variable -> fields.add(new FieldDefinition(
tool.name(), sourceKind, direction, owner.getNameAsString(), variable.getNameAsString(),
variable.getTypeAsString(),
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
annotationConstraints(schema, telegram), relative(path))));
}
}
private String annotationConstraints(AnnotationExpr schema, AnnotationExpr telegram) {
List<String> values = new ArrayList<>();
if (schema != null) values.add("Schema=" + schema);
if (telegram != null) values.add("GlowTrgmField=" + telegram);
return String.join("; ", values);
}
private String constraints(JsonNode node) {
List<String> values = new ArrayList<>();
for (String key : List.of("format", "pattern", "minimum", "maximum", "minLength", "maxLength", "example")) {
if (node.has(key)) values.add(key + "=" + node.get(key).asText());
}
if (node.has("enum")) values.add("enum=" + node.get("enum"));
return String.join("; ", values);
}
private String relative(Path path) {
return discovery.sourceRoot().relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
}
}

View File

@@ -0,0 +1,116 @@
package io.shinhanlife.dap.report.source;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import io.shinhanlife.dap.report.config.ReportProperties;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
@Component
public class ToolSourceDiscovery {
private final Path sourceRoot;
public ToolSourceDiscovery(ReportProperties properties) {
String configuredRoot = properties.sourceRoot();
if (configuredRoot == null || configuredRoot.isBlank()) {
throw new IllegalArgumentException("report.source-root must not be blank");
}
this.sourceRoot = resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
}
public Path sourceRoot() {
return sourceRoot;
}
public List<ToolSummary> discover() {
if (!Files.isDirectory(sourceRoot)) {
throw new IllegalStateException("Report source root does not exist: " + sourceRoot);
}
List<ToolSummary> tools = new ArrayList<>();
try (var paths = Files.walk(sourceRoot)) {
paths.filter(this::isUseCaseSource).forEach(path -> parse(path, tools));
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
}
return tools.stream()
.sorted(Comparator.comparing(ToolSummary::sourceFile))
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
(first, duplicate) -> first, LinkedHashMap::new))
.values().stream()
.sorted(Comparator.comparing(ToolSummary::name))
.toList();
}
private Path resolveProjectRoot(Path configuredPath) {
Path candidate = configuredPath;
while (candidate != null) {
if (Files.isRegularFile(candidate.resolve("settings.gradle"))
&& Files.isDirectory(candidate.resolve("dap-was-lib"))) {
return candidate;
}
candidate = candidate.getParent();
}
return configuredPath;
}
private boolean isUseCaseSource(Path path) {
String normalized = path.toString().replace('\\', '/');
return Files.isRegularFile(path)
&& path.getFileName().toString().endsWith("UseCase.java")
&& normalized.contains("/src/main/java/")
&& !normalized.contains("/dap-tool-report/");
}
private void parse(Path path, List<ToolSummary> tools) {
try {
CompilationUnit unit = StaticJavaParser.parse(path);
String owner = unit.getPrimaryTypeName().orElse(path.getFileName().toString().replace(".java", ""));
for (MethodDeclaration method : unit.findAll(MethodDeclaration.class)) {
JavaAnnotationReader.find(method.getAnnotations(), "McpTool")
.ifPresent(annotation -> tools.add(toSummary(path, owner, method, annotation)));
}
} catch (Exception exception) {
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
}
}
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "ToolHint").orElse(null);
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
String name = JavaAnnotationReader.string(tool, "name", method.getNameAsString());
String title = JavaAnnotationReader.string(tool, "title", name);
String description = JavaAnnotationReader.string(tool, "description", "");
String requestType = method.getParameters().isEmpty() ? "" : method.getParameter(0).getTypeAsString();
return new ToolSummary(
name,
title,
description,
hint == null ? "common" : JavaAnnotationReader.string(hint, "categoryKey", "com"),
hint == null ? "" : JavaAnnotationReader.string(hint, "mappingId", ""),
hint != null && JavaAnnotationReader.bool(hint, "register", false),
hint != null && JavaAnnotationReader.bool(hint, "requiresApproval", false),
annotations != null && JavaAnnotationReader.bool(annotations, "readOnlyHint", false),
annotations != null && JavaAnnotationReader.bool(annotations, "destructiveHint", false),
annotations != null && JavaAnnotationReader.bool(annotations, "idempotentHint", false),
annotations != null && JavaAnnotationReader.bool(annotations, "openWorldHint", false),
requestType,
method.getTypeAsString(),
owner,
sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'),
hint == null ? "" : JavaAnnotationReader.string(hint, "inputSchemaResource", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""));
}
}

View File

@@ -0,0 +1,11 @@
server:
address: ${REPORT_SERVER_ADDRESS:127.0.0.1}
port: ${REPORT_SERVER_PORT:8092}
spring:
application:
name: dap-tool-report
report:
source-root: ${REPORT_SOURCE_ROOT:.}
output-filename-prefix: tool-report

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.report.excel;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.ByteArrayInputStream;
import java.util.List;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
class ToolReportExcelWriterTest {
@Test
void writesFixedWorkbookStructure() throws Exception {
ToolSummary tool = new ToolSummary("oth.cst.customer.detail", "고객 상세", "설명", "cst", "ONCSC1340",
false, false, true, false, false, true, "Request", "Response", "UseCase", "UseCase.java", "", "");
FieldDefinition field = new FieldDefinition(tool.name(), "TELEGRAM", "INPUT", "ONCSC1340_I",
"customerNo", "String", true, "고객번호", "length=12", "ONCSC1340_I.java");
byte[] content = new ToolReportExcelWriter().write(List.of(new ToolReportModel(tool, List.of(field), List.of())));
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
assertThat(workbook.getSheet("툴 기본정보").getRow(8).getCell(1).getStringCellValue()).isEqualTo(tool.name());
assertThat(workbook.getSheet("수집 필드").getRow(8).getCell(5).getStringCellValue()).isEqualTo("customerNo");
assertThat(workbook.getSheet("툴 기본정보").getRow(0).getCell(0).getCellStyle().getFillForegroundColorColor().getARGBHex())
.endsWith("262626");
}
}
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.report.source;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.report.config.ReportProperties;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ToolSourceDiscoveryTest {
@Test
void discoversMcpToolsFromProjectSources() {
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
new ReportProperties(projectRoot.toString(), "tool-report"));
assertThat(discovery.discover())
.extracting(tool -> tool.name())
.contains("oth.cmm.customer.detail", "sms.sms.msg.send");
}
}

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,29 @@
package io.shinhanlife.dap.mcc.biz.cmm.dto;
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 AnalysisDataQueryRequest {
@Schema(description = "데이터 조회를 시작할 날짜", example = "2024-01-01")
private String startDate;
@Schema(description = "데이터 조회를 종료할 날짜", example = "2024-12-31")
private String endDate;
@Schema(description = "분석할 데이터 카테고리", example = "sales")
private String category;
@Schema(description = "지역 필터", example = "seoul")
private String region;
@Schema(description = "조회할 결과 수 제한", example = "100")
private Integer limit;
@Schema(description = "조회할 결과 시작 위치", example = "0")
private Integer offset;
}

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import io.shinhanlife.glow.BaseResponse;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
import io.shinhanlife.dap.mcc.biz.cmm.dto.CustomerGuidanceToolRequest;
import io.shinhanlife.dap.mcc.biz.cmm.dto.CustomerGuidanceToolResponse;
import org.springframework.http.ResponseEntity;
@@ -25,6 +25,6 @@ import org.springframework.web.bind.annotation.RequestBody;
public interface CustomerGuidanceToolUseCase {
@McpTool(name = "cmm_customer_tool", title = "고객 통합 안내이력 조회", description = "고객의 통합 안내 이력을 조회하는 도구입니다. 고객 ID와 조회 기간을 입력하면 해당 기간 동안의 안내 이력을 반환합니다.")
@ToolHint(register = false, categoryKey = "cmm", mappingId = "ONILD0320")
@GrowToolHint(categoryKey = "cmm", mappingId = "ONILD0320")
CustomerGuidanceToolResponse searchCustomerGuidance(CustomerGuidanceToolRequest req) throws Exception;
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcc.biz.ins.usecase;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
@@ -22,6 +22,6 @@ import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
public interface InsuranceClaimProcessorUseCase {
@McpTool(name = "ins_insurance_processor", title = "보험금 청구", description = "보험금 청구 요청을 처리하고 결과를 반환하는 LLM 도구 가이드")
@ToolHint(register = false, categoryKey = "ins", mappingId = "CLAIM0000001")
@GrowToolHint(categoryKey = "ins", mappingId = "CLAIM0000001")
InsuranceClaimProcessorResponse processInsuranceClaim(InsuranceClaimProcessorRequest req);
}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcc.biz.smp.usecase;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.annotation.GrowToolHint;
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.ExchangeRateRequest;
@@ -10,6 +10,6 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
public interface ExchangeRateToolUseCase {
@McpTool(name = "smp_exchange_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)")
@ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001")
@GrowToolHint(categoryKey = "smp", mappingId = "EXCHANGE_001")
ExchangeRateResponse getExchangeRate(ExchangeRateRequest req);
}

View File

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

View File

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

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