diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 158603a2..b61776fb 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 mci-mock dozzle was-sms was-oth # 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 및 빌드 캐시 자동 소거 청소! docker image prune -a -f diff --git a/dap-gateway/build.gradle b/dap-gateway/build.gradle index d0be68a0..18d4109f 100644 --- a/dap-gateway/build.gradle +++ b/dap-gateway/build.gradle @@ -13,11 +13,9 @@ 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로 노출하도록 지원합니다. diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/McpGatewayProperties.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/McpGatewayProperties.java index 75d99035..013a78ce 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/McpGatewayProperties.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/McpGatewayProperties.java @@ -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; } diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/RedisConfig.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/RedisConfig.java deleted file mode 100644 index 9498936e..00000000 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/RedisConfig.java +++ /dev/null @@ -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 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
- */ -@Configuration -public class RedisConfig { - - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - - // 1. Key는 무조건 String - template.setKeySerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - - // 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음) - Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class); - template.setValueSerializer(serializer); - template.setHashValueSerializer(serializer); - - template.afterPropertiesSet(); - return template; - } -} \ No newline at end of file diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java index acbc6c39..db4ba03b 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java @@ -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; diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/McpRouterController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/McpRouterController.java index 3ef467de..14ee2507 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/McpRouterController.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/McpRouterController.java @@ -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 fetchAllActiveTools() { List 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 knownTools = activeTools.stream() @@ -240,19 +240,19 @@ public class McpRouterController { @PostMapping("/registry/register") public ResponseEntity registerTool(@RequestBody ToolMetadata meta) { - redisRegistryService.saveTool(meta); + registryService.saveTool(meta); return ResponseEntity.ok("Registered"); } @PostMapping("/registry/deregister") public ResponseEntity deregisterTool(@RequestBody String uid) { - redisRegistryService.removeTool(uid); + registryService.removeTool(uid); return ResponseEntity.ok("Deregistered"); } @PostMapping("/registry/heartbeat") public ResponseEntity heartbeat(@RequestBody String uid) { - boolean success = redisRegistryService.refreshHeartbeat(uid); + boolean success = registryService.refreshHeartbeat(uid); if (success) { return ResponseEntity.ok("Heartbeat updated"); } else { diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/RedisToolTraceService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/RedisToolTraceService.java deleted file mode 100644 index ab182375..00000000 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/RedisToolTraceService.java +++ /dev/null @@ -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 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
- */ -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 redisProvider; - private final ObjectMapper json; - private final McpMonitorEventService monitorEvents; - private final Map attemptStates = new ConcurrentHashMap<>(); - - public RedisToolTraceService(McpGatewayProperties properties, - ObjectProvider 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 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 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 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 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 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 responseSummary(String responseText) { - Map 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) { - } -} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/InMemoryRegistryService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/InMemoryRegistryService.java new file mode 100644 index 00000000..c4f3107b --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/InMemoryRegistryService.java @@ -0,0 +1,111 @@ +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 toolCache = new ConcurrentHashMap<>(); + private static final long DEFAULT_TTL_MILLIS = 45000; // 45초 + + /** + * 툴 등록 및 갱신 + */ + public void saveTool(ToolMetadata meta) { + meta.setLastHeartbeat(System.currentTimeMillis()); + toolCache.put(meta.getUid(), meta); + log.info(" [InMemoryRegistry] 툴 등록 완료: {}", meta.getUid()); + } + + /** + * 하트비트 갱신 (TTL 초기화) + */ + public boolean refreshHeartbeat(String uid) { + ToolMetadata meta = toolCache.get(uid); + if (meta != null) { + meta.setLastHeartbeat(System.currentTimeMillis()); + log.debug(" [InMemoryRegistry] 하트비트 갱신: {}", uid); + return true; + } else { + log.warn(" [InMemoryRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid); + return false; + } + } + + public List 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 getAllTools() { + return List.copyOf(toolCache.values()); + } + + /** + * 툴 명시적 제거 (Deregister) + */ + public void removeTool(String uid) { + toolCache.remove(uid); + log.info(" [InMemoryRegistry] 툴 삭제 완료: {}", uid); + } + + /** + * 만료된 툴을 스케줄러로 삭제합니다. (10초마다 실행) + */ + @Scheduled(fixedRate = 10000) + public void evictExpiredTools() { + long now = System.currentTimeMillis(); + List expiredKeys = toolCache.entrySet().stream() + .filter(entry -> (now - entry.getValue().getLastHeartbeat()) > DEFAULT_TTL_MILLIS) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + for (String key : expiredKeys) { + toolCache.remove(key); + log.info(" [InMemoryRegistry] TTL 초과로 툴 자동 삭제: {}", key); + } + } +} \ No newline at end of file diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/RedisRegistryService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/RedisRegistryService.java deleted file mode 100644 index 73c3899f..00000000 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/RedisRegistryService.java +++ /dev/null @@ -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 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
- */ -@Slf4j -@Service -@RequiredArgsConstructor -public class RedisRegistryService { - - private final RedisTemplate 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 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 allTools = getAllTools(); - for (ToolMetadata tool : allTools) { - if (name.equals(tool.getName())) { - return tool; - } - } - return null; - } - - /** - * 등록된 모든 활성 툴 목록 조회 - */ - public List getAllTools() { - Set keys = redisTemplate.keys(KEY_PREFIX + "*"); - if (keys == null || keys.isEmpty()) { - return List.of(); - } - List 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); - } -} \ No newline at end of file diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java index 758dc7c1..422c08db 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java @@ -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 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 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()); } diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/KillSwitchService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/KillSwitchService.java index 744511bb..5360158a 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/KillSwitchService.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/KillSwitchService.java @@ -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 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
*/ @Slf4j @Service @RequiredArgsConstructor public class KillSwitchService { - // 기본 제공되는 StringRedisTemplate 사용 - private final StringRedisTemplate stringRedisTemplate; + // 인메모리 맵으로 변경 + private final Map 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); } } \ No newline at end of file diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ToolPlanner.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ToolPlanner.java index 96bf1f36..dea69f9b 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ToolPlanner.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ToolPlanner.java @@ -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 ?�스??처리 ?�래?? * @author 0986406 * @create 2026.09.01 *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
+ * ---------- 개정?�력 ----------
+ * ?�정??     ?�정??   ?�정?�용
  * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
+ * 2026.09.01  0986406    최초?�성
  * 
  * 
*/ @@ -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의 계획을 생성합니다. + * ?�청(payload)??분석?�여 ?�행?�야 ??Tool??계획???�성?�니?? */ public Object createPlan(Map payload, String tenantId) { - log.info(" [Planner] 요청 분석 및 실행 계획 수립 시작"); + log.info(" [Planner] ?�청 분석 �??�행 계획 ?�립 ?�작"); - // 1. 요청에서 호출하려는 툴 이름 추출 (JSON-RPC params.name) + // 1. ?�청?�서 ?�출?�려?????�름 추출 (JSON-RPC params.name) Map params = (Map) payload.get("params"); String toolName = params != null ? (String) params.get("name") : null; if (toolName == null || toolName.isEmpty()) { - throw new IllegalArgumentException("요청에 toolName이 포함되어 있지 않습니다."); + throw new IllegalArgumentException("?�청??toolName???�함?�어 ?��? ?�습?�다."); } - // 2. RedisRegistry에서 해당 툴의 메타데이터 조회 - // (실제로는 이 메타데이터가 실행 계획의 핵심이 됩니다) - var toolMetadata = redisRegistryService.getToolByName(toolName); + // 2. RedisRegistry?�서 ?�당 ?�의 메�??�이??조회 + // (?�제로는 ??메�??�이?��? ?�행 계획???�심???�니?? + var toolMetadata = InMemoryRegistryService.getToolByName(toolName); if (toolMetadata == null) { - log.warn(" [Planner] 등록되지 않은 툴 요청: {}. Fallback 라우팅 규칙을 확인합니다.", toolName); + log.warn(" [Planner] ?�록?��? ?��? ???�청: {}. Fallback ?�우??규칙???�인?�니??", 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 ?�우???�?�이 ?�닙?�다. ?? {}", toolName); + throw new RuntimeException("?�당 ??" + toolName + ")???��??�트리에 존재?��? ?�습?�다."); } - log.info(" [Planner] Fallback 라우팅 매칭됨: {} -> {}", toolName, fallbackPodUrl); + log.info(" [Planner] Fallback ?�우??매칭?? {} -> {}", toolName, fallbackPodUrl); toolMetadata = ToolMetadata.builder() .uid(toolName) @@ -80,26 +80,26 @@ public class ToolPlanner { .build(); } - // 2-1. [신규] 도메인 그룹핑 기반 권한 검증 + // 2-1. [?�규] ?�메??그룹??기반 권한 검�? if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) { String normalizedTenantId = tenantId.toLowerCase(); List allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId); - // 만약 대소문자 변환 후에도 없으면 원래 값으로 한 번 더 시도 (하위 호환성) + // 만약 ?�?�문??변???�에???�으�??�래 값으�???�????�도 (?�위 ?�환?? 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] 권한 거�? - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey()); + throw new SecurityException("?�당 ?�메??" + toolMetadata.getCategoryKey() + ")???�을 ?�행??권한???�습?�다."); } } - log.info(" [Planner] 툴 '{}'에 대한 실행 계획 수립 완료", toolName); + log.info(" [Planner] ??'{}'???�???�행 계획 ?�립 ?�료", toolName); - // 3. 수립된 계획(툴 메타데이터) 반환 + // 3. ?�립??계획(??메�??�이?? 반환 return toolMetadata; } } \ No newline at end of file diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSynchronizer.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSynchronizer.java index 06134996..5b3502ab 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSynchronizer.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSynchronizer.java @@ -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 mcpServerProvider; private final DynamicMcpServerManager dynamicMcpServerManager; - private final RedisRegistryService redisRegistryService; + private final InMemoryRegistryService InMemoryRegistryService; private final RegistryMcpToolSpecificationFactory specificationFactory; private final Set managedToolNames = new LinkedHashSet<>(); private final ReentrantLock lock = new ReentrantLock(); public RegistryMcpToolSynchronizer(ObjectProvider 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 activeEntries = redisRegistryService.getAllTools() + List activeEntries = InMemoryRegistryService.getAllTools() .stream().filter(ToolMetadata::getVisible).collect(Collectors.toList()); // 1. Dynamic MCP Server 동기화 (카테고리별) diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/InMemoryToolTraceService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/InMemoryToolTraceService.java new file mode 100644 index 00000000..565bc685 --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/InMemoryToolTraceService.java @@ -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 attemptStates = new ConcurrentHashMap<>(); + + // LRU Cache for recent traces + private final Map 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(maxSize, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry 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 getRecentTraces(int limit) { + if (!properties.traceEnabled()) { + return List.of("Trace is disabled. Set traceEnabled=true."); + } + + List result = new ArrayList<>(); + synchronized (recentTraces) { + List 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; + } + } +} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/McpMonitorEventService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/McpMonitorEventService.java similarity index 95% rename from dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/McpMonitorEventService.java rename to dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/McpMonitorEventService.java index 53a91f00..a8f48e12 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/McpMonitorEventService.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/McpMonitorEventService.java @@ -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 diff --git a/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/DapGatewayApplicationTests.java b/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/DapGatewayApplicationTests.java index ed5a2f68..bf2430f7 100644 --- a/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/DapGatewayApplicationTests.java +++ b/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/DapGatewayApplicationTests.java @@ -1,17 +1,17 @@ -package io.shinhanlife.dap.mcg; +package io.shinhanlife.dap.mcg; /** * @package io.shinhanlife.dap.mcg * @className DapGatewayApplicationTests - * @description AX HUB 시스템 처리 클래스 + * @description AX HUB ?�스??처리 ?�래?? * @author 0986406 * @create 2026.09.01 *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
+ * ---------- 개정?�력 ----------
+ * ?�정??     ?�정??   ?�정?�용
  * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
+ * 2026.09.01  0986406    최초?�성
  * 
  * 
*/ @@ -20,7 +20,7 @@ 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; +import io.shinhanlife.dap.mcg.trace.InMemoryToolTraceService; @SpringBootTest @ActiveProfiles("test") @@ -28,7 +28,7 @@ class DapGatewayApplicationTests { // Mock components that might require external dependencies (like Redis/DB) to pass the context load @MockitoBean - private RedisToolTraceService redisToolTraceService; + private InMemoryToolTraceService InMemoryToolTraceService; @MockitoBean private AuditLogService auditLogService; diff --git a/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/DapGatewayApplicationTests.java b/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/DapGatewayApplicationTests.java new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/DapGatewayApplicationTests.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/McpSdk2CompatibilityTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/McpSdk2CompatibilityTest.java new file mode 100644 index 00000000..f49597e9 --- /dev/null +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/McpSdk2CompatibilityTest.java @@ -0,0 +1,20 @@ +package io.shinhanlife.dap.lib.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import org.springframework.ai.mcp.annotation.McpTool; + +class McpSdk2CompatibilityTest { + + @Test + void usesOfficialSpringAiMcpAnnotations() { + assertEquals("org.springframework.ai.mcp.annotation", McpTool.class.getPackageName()); + } + + @Test + void usesMcpJavaSdkTwo() { + assertEquals("2.0.0", McpSchema.class.getPackage().getImplementationVersion()); + } +} diff --git a/deps.txt b/deps.txt new file mode 100644 index 00000000..6ac78b7c Binary files /dev/null and b/deps.txt differ diff --git a/docker-compose.yml b/docker-compose.yml index e4cccf0a..bd34e964 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,7 @@ name: ax_hub_mcp_tool services: - redis: - image: redis:latest - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 + gateway: build: @@ -20,16 +12,10 @@ services: volumes: - ./:/src depends_on: - redis: - condition: service_healthy tool-report: condition: service_started environment: - TZ=Asia/Seoul - - SPRING_REDIS_HOST=redis - - SPRING_REDIS_PORT=6379 - - SPRING_DATA_REDIS_HOST=redis - - SPRING_DATA_REDIS_PORT=6379 - AXHUB_SOURCE_DIR=/src - JAVA_TOOL_OPTIONS=-Xms64m -Xmx384m - SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local} @@ -67,13 +53,8 @@ services: dockerfile: dap-was-sms/Dockerfile ports: - "8282:8082" - depends_on: - - redis environment: - TZ=Asia/Seoul - - SPRING_REDIS_HOST=redis - - SPRING_REDIS_PORT=6379 - - SPRING_DATA_REDIS_PORT=6379 - AXHUB_GATEWAY_URL=http://gateway:8081 - AXHUB_SOURCE_DIR=/src - AXHUB_TOOL_URL=http://was-sms:8082 @@ -91,13 +72,8 @@ services: dockerfile: dap-was-oth/Dockerfile ports: - "8284:8084" - depends_on: - - redis environment: - TZ=Asia/Seoul - - SPRING_REDIS_HOST=redis - - SPRING_REDIS_PORT=6379 - - SPRING_DATA_REDIS_PORT=6379 - AXHUB_GATEWAY_URL=http://gateway:8081 - AXHUB_TOOL_URL=http://was-oth:8084 - GLOW_COMMUNICATION_MCI_HOMT=http://mci-mock diff --git a/smp_tools.json b/smp_tools.json new file mode 100644 index 00000000..93001915 --- /dev/null +++ b/smp_tools.json @@ -0,0 +1,859 @@ +{ + "value": [ + { + "uid": "080f2207-f98a-31b4-9a46-5bc7a8e41f90", + "semver": "1.0.0", + "displayName": "고객 통합 안내이력 조회", + "name": "cmm_customer_tool", + "description": "고객의 통합 안내 이력을 조회하는 도구ìž\u0085니다.\n사용 시점: 고객 ID와 조회 기간을 기반으로 해당 기간 동안의 안내 이력을 확인할 때 사용합니다.\n사용 제외: 안내 이력을 생성하거나 수정할 때는 사용하지 않습니다.\nìž\u0085출력 제한: 안내 이력 조회만 수행하며 데이터를 변경하지 않습니다.", + "functionDescription": "고객의 통합 안내 이력을 조회하는 도구ìž\u0085니다.", + "whenToUse": "고객 ID와 조회 기간을 기반으로 해당 기간 동안의 안내 이력을 확인할 때 사용합니다.", + "whenNotToUse": "안내 이력을 생성하거나 수정할 때는 사용하지 않습니다.", + "ioLimits": "안내 이력 조회만 수행하며 데이터를 변경하지 않습니다.", + "displayDescription": "고객의 통합 안내 이력을 조회합니다.", + "exampleQueries": [ + "고객 통합 안내이력 조회해줘", + "특정 기간의 안내 이력을 확인해줘", + "고객 번호로 안내 이력을 찾아줘" + ], + "tags": [ + "고객", + "통합안내이력" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "csNo": { + "type": "string", + "description": "고객번호" + }, + "ntleCd": { + "type": "string", + "description": "안내장코드" + }, + "notiPmlMdCd": { + "type": "string", + "description": "안내발송방법코드" + }, + "inqrStrYmd": { + "type": "string", + "description": "조회시작일자" + }, + "inqrEndYmd": { + "type": "string", + "description": "조회ì¢\u0085료일자" + } + }, + "required": [ + "csNo" + ], + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "cmm", + "endpoint": "http://localhost:8084/mcp/cmm_customer_tool", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "ONILD0320", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "26c5b305-a75d-3092-9252-16849187090d", + "semver": "1.0.0", + "displayName": "메타 공통코드 조회", + "name": "cmm_comcode_lookup", + "description": "통합코드 그룹과 코드ëª\u0085 조건으로 메타 공통코드 목록을 조회한다.\n사용 시점: ì—\u0085무 코드의 값과 표시ëª\u0085을 확인하거나 유효한 코드 목록이 필요한 경우 사용한다.\n사용 제외: 공통코드를 신규 등록하거나 변경 또는 삭제하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 검색 조건에 맞는 코드와 코드ëª\u0085만 반환하며 코드 데이터는 변경하지 않는다.", + "functionDescription": "통합코드 그룹과 코드ëª\u0085 조건으로 메타 공통코드 목록을 조회한다.", + "whenToUse": "ì—\u0085무 코드의 값과 표시ëª\u0085을 확인하거나 유효한 코드 목록이 필요한 경우 사용한다.", + "whenNotToUse": "공통코드를 신규 등록하거나 변경 또는 삭제하려는 경우에는 사용하지 않는다.", + "ioLimits": "검색 조건에 맞는 코드와 코드ëª\u0085만 반환하며 코드 데이터는 변경하지 않는다.", + "displayDescription": "메타 시스í\u0085œì˜ 공통코드 목록을 조회합니다.", + "exampleQueries": [ + "사용 상태 코드 목록을 알려줘", + "고객 구분 공통코드를 찾아줘", + "사용 중인 통합코드를 조회해줘" + ], + "tags": [ + "메타", + "공통코드" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "groupCode": { + "type": "string", + "description": "조회할 통합코드 그룹 ID" + }, + "codeName": { + "type": "string", + "description": "코드ëª\u0085 검색 키워드" + }, + "useYn": { + "type": "string", + "description": "사용 여부 Y 또는 N", + "enum": [ + "Y", + "N" + ] + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "cmm", + "endpoint": "http://localhost:8084/mcp/cmm_comcode_lookup", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "CLCNNB00001", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "bf437712-8e47-3db6-9e56-2d1995dc0609", + "semver": "1.0.0", + "displayName": "메타 í\u0085Œì´ë¸” 조회", + "name": "cmm_meta_table", + "description": "물리ëª\u0085, ë\u0085¼ë¦¬ëª\u0085 또는 소유자 조건으로 메타 í\u0085Œì´ë¸” 정보를 조회한다.\n사용 시점: 사용자가 ì—\u0085무 데이터의 í\u0085Œì´ë¸”ëª\u0085이나 소유 스키마를 확인하려는 경우 사용한다.\n사용 제외: í\u0085Œì´ë¸”을 생성하거나 구조를 변경하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 메타에 등록된 í\u0085Œì´ë¸” 설ëª\u0085 정보만 반환하며 실제 í\u0085Œì´ë¸” 데이터는 조회하지 않는다.", + "functionDescription": "물리ëª\u0085, ë\u0085¼ë¦¬ëª\u0085 또는 소유자 조건으로 메타 í\u0085Œì´ë¸” 정보를 조회한다.", + "whenToUse": "사용자가 ì—\u0085무 데이터의 í\u0085Œì´ë¸”ëª\u0085이나 소유 스키마를 확인하려는 경우 사용한다.", + "whenNotToUse": "í\u0085Œì´ë¸”을 생성하거나 구조를 변경하려는 경우에는 사용하지 않는다.", + "ioLimits": "메타에 등록된 í\u0085Œì´ë¸” 설ëª\u0085 정보만 반환하며 실제 í\u0085Œì´ë¸” 데이터는 조회하지 않는다.", + "displayDescription": "메타 시스í\u0085œì— 등록된 í\u0085Œì´ë¸” 정보를 조회합니다.", + "exampleQueries": [ + "고객 기본 í\u0085Œì´ë¸”을 찾아줘", + "계약 관련 í\u0085Œì´ë¸” 목록을 보여줘", + "특정 스키마의 í\u0085Œì´ë¸”을 조회해줘" + ], + "tags": [ + "메타", + "í\u0085Œì´ë¸”" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "tableName": { + "type": "string", + "description": "í\u0085Œì´ë¸” 물리ëª\u0085 검색어" + }, + "tableLogicalName": { + "type": "string", + "description": "í\u0085Œì´ë¸” ë\u0085¼ë¦¬ëª\u0085 검색어" + }, + "owner": { + "type": "string", + "description": "í\u0085Œì´ë¸” 소유 스키마ëª\u0085" + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "cmm", + "endpoint": "http://localhost:8084/mcp/cmm_meta_table", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "4914953b-ea25-389b-a6a9-c3c407f4d5bb", + "semver": "1.0.0", + "displayName": "ì—\u0085무 í\u0085œí”Œë¦¿ 다운로드 URL 조회", + "name": "cmm_template_url", + "description": "요청한 ì—\u0085무 í\u0085œí”Œë¦¿ 파일을 내려받을 수 있는 URL을 반환한다.\n사용 시점: 사용자가 엑ì\u0085€ì´ë‚˜ 워드 ì—\u0085무 양식의 다운로드 위치를 요청한 경우 사용한다.\n사용 제외: í\u0085œí”Œë¦¿ 내용을 작성하거나 ì—\u0085로드 또는 변경하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 등록된 í\u0085œí”Œë¦¿ ID에 대한 다운로드 URL만 반환하며 파일 자체는 반환하지 않는다.", + "functionDescription": "요청한 ì—\u0085무 í\u0085œí”Œë¦¿ 파일을 내려받을 수 있는 URL을 반환한다.", + "whenToUse": "사용자가 엑ì\u0085€ì´ë‚˜ 워드 ì—\u0085무 양식의 다운로드 위치를 요청한 경우 사용한다.", + "whenNotToUse": "í\u0085œí”Œë¦¿ 내용을 작성하거나 ì—\u0085로드 또는 변경하려는 경우에는 사용하지 않는다.", + "ioLimits": "등록된 í\u0085œí”Œë¦¿ ID에 대한 다운로드 URL만 반환하며 파일 자체는 반환하지 않는다.", + "displayDescription": "ì—\u0085무 í\u0085œí”Œë¦¿ì„ 다운로드할 수 있는 URL을 제공합니다.", + "exampleQueries": [ + "청구 양식 다운로드 링크를 알려줘", + "ì—\u0085무용 엑ì\u0085€ í\u0085œí”Œë¦¿ì„ 받고 싶어", + "등록된 문서 양식 위치를 찾아줘" + ], + "tags": [ + "í\u0085œí”Œë¦¿", + "다운로드" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "description": "다운로드할 í\u0085œí”Œë¦¿ 식별자" + } + }, + "required": [ + "templateId" + ], + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "cmm", + "endpoint": "http://localhost:8084/mcp/cmm_template_url", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "e14f5dc9-38c5-3424-bc73-69052c28f660", + "semver": "1.0.0", + "displayName": "보험금 청구 처리", + "name": "ins_insurance_processor", + "description": "보험금 청구번호, 청구금액과 청구일을 받아 청구 처리 요청을 수행한다.\n사용 시점: 사용자가 확인된 보험금 청구 정보를 실제 처리계에 접수하려는 경우 사용한다.\n사용 제외: 청구 상태만 조회하거나 필수 정보가 확인되지 않은 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 실행 시 ì—\u0085무 상태가 변경될 수 있으므로 호출 전에 ìž\u0085력값과 사용자 의사를 확인해야 한다.", + "functionDescription": "보험금 청구번호, 청구금액과 청구일을 받아 청구 처리 요청을 수행한다.", + "whenToUse": "사용자가 확인된 보험금 청구 정보를 실제 처리계에 접수하려는 경우 사용한다.", + "whenNotToUse": "청구 상태만 조회하거나 필수 정보가 확인되지 않은 경우에는 사용하지 않는다.", + "ioLimits": "실행 시 ì—\u0085무 상태가 변경될 수 있으므로 호출 전에 ìž\u0085력값과 사용자 의사를 확인해야 한다.", + "displayDescription": "확인된 보험금 청구 요청을 처리계에 전달합니다.", + "exampleQueries": [ + "확인한 내용으로 보험금 청구를 접수해줘", + "이 청구번호의 보험금 처리를 진행해줘", + "오늘 날짜로 보험금 청구 요청을 보내줘" + ], + "tags": [ + "보험금", + "청구처리" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "claimNumber": { + "type": "string", + "description": "처리할 보험금 청구번호" + }, + "claimAmount": { + "type": "number", + "description": "처리할 보험금 청구금액" + }, + "claimDate": { + "type": "string", + "description": "청구일자 YYYYMMDD", + "pattern": "^[0-9]{8}$" + } + }, + "required": [ + "claimNumber", + "claimAmount", + "claimDate" + ], + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "ins", + "endpoint": "http://localhost:8084/mcp/ins_insurance_processor", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "CLAIM0000001", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "a503bf94-adc5-3bdc-b9b0-9c6fcb59838d", + "semver": "1.0.0", + "displayName": "ONNBA3011 보험 ì—\u0085무 조회", + "name": "oth_onnba3011_call", + "description": "ONNBA3011 ìž\u0085력정보를 MCI 전문으로 변환해 보험 ì—\u0085무 결과를 조회한다.\n사용 시점: 사용자가 ONNBA3011 인터페이스 기준의 보험 계약 또는 지급 정보를 조회하려는 경우 사용한다.\n사용 제외: 인터페이스 ìž\u0085력값을 확인할 수 없거나 보험 정보를 변경하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: CLCNNB00001 인터페이스 규격에 맞는 값만 전달하며 조회 결과를 ì—\u0085무 응답으로 변환한다.", + "functionDescription": "ONNBA3011 ìž\u0085력정보를 MCI 전문으로 변환해 보험 ì—\u0085무 결과를 조회한다.", + "whenToUse": "사용자가 ONNBA3011 인터페이스 기준의 보험 계약 또는 지급 정보를 조회하려는 경우 사용한다.", + "whenNotToUse": "인터페이스 ìž\u0085력값을 확인할 수 없거나 보험 정보를 변경하려는 경우에는 사용하지 않는다.", + "ioLimits": "CLCNNB00001 인터페이스 규격에 맞는 값만 전달하며 조회 결과를 ì—\u0085무 응답으로 변환한다.", + "displayDescription": "ONNBA3011 ì—\u0085무 정보를 MCI로 조회합니다.", + "exampleQueries": [ + "고객의 보험 ì—\u0085무 정보를 조회해줘", + "ONNBA3011 기준으로 계약 정보를 확인해줘", + "ìž\u0085력한 고객번호의 보험 결과를 알려줘" + ], + "tags": [ + "보험", + "MCI" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + "GLOW_COMMUNICATION_MCI_HOST", + "GLOW_COMMUNICATION_MCI_PORT" + ], + "parametersSchema": { + "type": "object", + "properties": { + "dalScCd": { + "type": "string", + "description": "거래 구분 코드" + }, + "cstSucoRltyCd": { + "type": "string", + "description": "고객 성공 관계 코드" + }, + "csNo": { + "type": "string", + "description": "고객 번호" + }, + "rdreNo": { + "type": "string", + "description": "설계사 번호" + }, + "unfcPvsCalReqYn": { + "type": "string", + "description": "미확정 지급 계산 요청 여부" + }, + "kcisPymmTnnrRequest": { + "type": "string", + "description": "KCIS 납ìž\u0085 기간 요청값" + }, + "lmovYn": { + "type": "string", + "description": "계약 이동 여부" + }, + "genPsthApvTrgtYn": { + "type": "string", + "description": "일반 사후 승인 대상 여부" + }, + "ircoLmovEcpbTrgtYn": { + "type": "string", + "description": "계약 이동 예외 대상 여부" + }, + "digCalYn": { + "type": "string", + "description": "디지털 계산 여부" + }, + "prbuIciDigCalYn": { + "type": "string", + "description": "상품별 디지털 계산 여부" + }, + "unfcPrbuIrcoAddu": { + "type": "object", + "description": "미확정 상품 추가 정보", + "additionalProperties": true + }, + "sucoIspaBasDto": { + "type": "object", + "description": "성공 심사 기본 정보", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "oth", + "endpoint": "http://localhost:8084/mcp/oth_onnba3011_call", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "CLCNNB00001", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "850ea7c1-f54f-38d9-8b4f-958602271fb2", + "semver": "1.0.0", + "displayName": "오늘의 ëª\u0085언 조회", + "name": "smp_quote_daily", + "description": "선택한 ì¹´í\u0085Œê³ ë¦¬ì— 맞는 오늘의 ëª\u0085언 한 건을 조회한다.\n사용 시점: 사용자가 ëª\u0085언이나 짧은 동기부여 문구를 요청한 경우 사용한다.\n사용 제외: ì—\u0085무 데이터 조회나 사실 검증이 필요한 질문에는 사용하지 않는다.\nìž\u0085출력 제한: 등록된 ëª\u0085언 데이터 중 한 건만 반환하며 출처 정보가 없을 수 있다.", + "functionDescription": "선택한 ì¹´í\u0085Œê³ ë¦¬ì— 맞는 오늘의 ëª\u0085언 한 건을 조회한다.", + "whenToUse": "사용자가 ëª\u0085언이나 짧은 동기부여 문구를 요청한 경우 사용한다.", + "whenNotToUse": "ì—\u0085무 데이터 조회나 사실 검증이 필요한 질문에는 사용하지 않는다.", + "ioLimits": "등록된 ëª\u0085언 데이터 중 한 건만 반환하며 출처 정보가 없을 수 있다.", + "displayDescription": "ì¹´í\u0085Œê³ ë¦¬ì— 맞는 오늘의 ëª\u0085언을 제공합니다.", + "exampleQueries": [ + "오늘 힘이 되는 말을 알려줘", + "ì—\u0085무 시작 전에 ëª\u0085언 하나 보여줘", + "성공에 관한 짧은 문구를 추천해줘" + ], + "tags": [ + "ëª\u0085언", + "콘í\u0085ì¸ " + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "조회할 ëª\u0085언 ì¹´í\u0085Œê³ ë¦¬" + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "smp", + "endpoint": "http://localhost:8084/mcp/smp_quote_daily", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "ef86d147-785e-3007-a158-f7e6ffd275de", + "semver": "1.0.0", + "displayName": "실시간 환율 조회", + "name": "smp_exchange_inquiry", + "description": "통화코드를 기준으로 현재 환율 정보를 조회한다.\n사용 시점: 사용자가 USD, EUR, JPY 등 특정 통화의 환율을 확인하려는 경우 사용한다.\n사용 제외: 환전 거래를 실행하거나 과거 환율 통계를 분석하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 지원되는 통화코드의 조회 시점 환율만 반환하며 실제 환전 기능은 제공하지 않는다.", + "functionDescription": "통화코드를 기준으로 현재 환율 정보를 조회한다.", + "whenToUse": "사용자가 USD, EUR, JPY 등 특정 통화의 환율을 확인하려는 경우 사용한다.", + "whenNotToUse": "환전 거래를 실행하거나 과거 환율 통계를 분석하려는 경우에는 사용하지 않는다.", + "ioLimits": "지원되는 통화코드의 조회 시점 환율만 반환하며 실제 환전 기능은 제공하지 않는다.", + "displayDescription": "지정한 통화의 현재 환율을 조회합니다.", + "exampleQueries": [ + "오늘 달러 환율을 알려줘", + "엔화 환율이 얼마인지 조회해줘", + "유로 환율을 확인해줘" + ], + "tags": [ + "환율", + "금융" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "currencyCode": { + "type": "string", + "description": "조회할 ISO 통화코드", + "pattern": "^[A-Z]{3}$" + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "smp", + "endpoint": "http://localhost:8084/mcp/smp_exchange_inquiry", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "44508c3e-89a8-3fa8-92ec-ae236ca2cd88", + "semver": "1.0.0", + "displayName": "MCP·TOOL 파트 구성원 조회", + "name": "smp_team_list", + "description": "신한라이프 AX 추진팀과 MCP·TOOL 파트 구성원 및 직급을 조회한다.\n사용 시점: 사용자가 프로젝트 담당자나 팀별 구성원 정보를 묻는 경우 사용한다.\n사용 제외: 인사 개인정보나 연락처 또는 조직 변경 이력을 요청하는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 사전에 등록된 구성원의 이름과 직급만 반환하며 민감한 인사정보는 포함하지 않는다.", + "functionDescription": "신한라이프 AX 추진팀과 MCP·TOOL 파트 구성원 및 직급을 조회한다.", + "whenToUse": "사용자가 프로젝트 담당자나 팀별 구성원 정보를 묻는 경우 사용한다.", + "whenNotToUse": "인사 개인정보나 연락처 또는 조직 변경 이력을 요청하는 경우에는 사용하지 않는다.", + "ioLimits": "사전에 등록된 구성원의 이름과 직급만 반환하며 민감한 인사정보는 포함하지 않는다.", + "displayDescription": "신한라이프 MCP·TOOL 파트 담당자와 구성원을 조회합니다.", + "exampleQueries": [ + "MCP 팀 담당자를 알려줘", + "TOOL 파트 구성원이 누구인지 보여줘", + "AX 추진팀 담당자를 찾아줘" + ], + "tags": [ + "조직", + "담당자" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "teamName": { + "type": "string", + "description": "조회할 팀 이름 또는 전체" + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "smp", + "endpoint": "http://localhost:8084/mcp/smp_team_list", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "bc0f227e-3a9b-36a1-af6f-3f20c1964969", + "semver": "1.0.0", + "displayName": "도시 날씨 조회", + "name": "smp_weather_inquiry", + "description": "도시ëª\u0085을 기준으로 현재 날씨, 온도와 풍속을 조회한다.\n사용 시점: 사용자가 특정 도시의 현재 기상 정보를 요청한 경우 사용한다.\n사용 제외: 장기 예보나 기상 특보 또는 공식 재난정보가 필요한 경우에는 사용하지 않는다.\nìž\u0085출력 제한: ìž\u0085력한 도시의 현재 관측 기반 샘플 정보만 반환한다.", + "functionDescription": "도시ëª\u0085을 기준으로 현재 날씨, 온도와 풍속을 조회한다.", + "whenToUse": "사용자가 특정 도시의 현재 기상 정보를 요청한 경우 사용한다.", + "whenNotToUse": "장기 예보나 기상 특보 또는 공식 재난정보가 필요한 경우에는 사용하지 않는다.", + "ioLimits": "ìž\u0085력한 도시의 현재 관측 기반 샘플 정보만 반환한다.", + "displayDescription": "지정한 도시의 현재 날씨 정보를 조회합니다.", + "exampleQueries": [ + "서울 날씨를 알려줘", + "부산의 현재 온도를 조회해줘", + "제주도 바람이 얼마나 부는지 알려줘" + ], + "tags": [ + "날씨", + "조회" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "날씨를 조회할 도시ëª\u0085" + } + }, + "required": [ + "city" + ], + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "smp", + "endpoint": "http://localhost:8084/mcp/smp_weather_inquiry", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": null, + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "5fd282b9-6472-3fc3-a562-fe23e000ab7e", + "semver": "1.0.0", + "displayName": "SOL 의뢰서 상세 조회", + "name": "sol_request_detail", + "description": "SOL 의뢰서 식별자를 기준으로 의뢰서 상세 내용을 조회한다.\n사용 시점: 사용자가 특정 SOL 의뢰서의 상세 내용과 진행 정보를 확인하려는 경우 사용한다.\n사용 제외: 의뢰서 목록만 찾거나 의뢰서를 변경 또는 처리하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: 정확한 의뢰서 ID가 필요하며 등록된 상세 정보만 반환한다.", + "functionDescription": "SOL 의뢰서 식별자를 기준으로 의뢰서 상세 내용을 조회한다.", + "whenToUse": "사용자가 특정 SOL 의뢰서의 상세 내용과 진행 정보를 확인하려는 경우 사용한다.", + "whenNotToUse": "의뢰서 목록만 찾거나 의뢰서를 변경 또는 처리하려는 경우에는 사용하지 않는다.", + "ioLimits": "정확한 의뢰서 ID가 필요하며 등록된 상세 정보만 반환한다.", + "displayDescription": "SOL 의뢰서 한 건의 상세 정보를 조회합니다.", + "exampleQueries": [ + "이 SOL 의뢰서 상세를 보여줘", + "의뢰서 ID로 처리 내용을 확인해줘", + "선택한 의뢰서의 상세 정보를 알려줘" + ], + "tags": [ + "SOL", + "의뢰서" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "srId": { + "type": "string", + "description": "상세 조회할 SOL 의뢰서 ID" + } + }, + "required": [ + "srId" + ], + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "sol", + "endpoint": "http://localhost:8084/mcp/sol_request_detail", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "SOLG00000002", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + }, + { + "uid": "332361bb-488a-388a-974c-c28ec243c253", + "semver": "1.0.0", + "displayName": "SOL 의뢰서 목록 조회", + "name": "sol_request_list", + "description": "진행상태, 조회기간과 조회대상 조건으로 SOL 의뢰서 목록을 조회한다.\n사용 시점: 사용자가 자신의 SOL 의뢰서나 상태별 의뢰서 목록을 확인하려는 경우 사용한다.\n사용 제외: 특정 의뢰서의 상세 내용만 보거나 의뢰서를 변경하려는 경우에는 사용하지 않는다.\nìž\u0085출력 제한: ìž\u0085ë ¥ 조건에 해당하는 의뢰서 요약 목록만 반환한다.", + "functionDescription": "진행상태, 조회기간과 조회대상 조건으로 SOL 의뢰서 목록을 조회한다.", + "whenToUse": "사용자가 자신의 SOL 의뢰서나 상태별 의뢰서 목록을 확인하려는 경우 사용한다.", + "whenNotToUse": "특정 의뢰서의 상세 내용만 보거나 의뢰서를 변경하려는 경우에는 사용하지 않는다.", + "ioLimits": "ìž\u0085ë ¥ 조건에 해당하는 의뢰서 요약 목록만 반환한다.", + "displayDescription": "조건에 맞는 SOL 의뢰서 목록을 조회합니다.", + "exampleQueries": [ + "진행 중인 SOL 의뢰서를 보여줘", + "최근 한 달간 내 의뢰서를 조회해줘", + "완료된 의뢰서 목록을 알려줘" + ], + "tags": [ + "SOL", + "의뢰서" + ], + "ownerOrg": "MCP_TOOL", + "requiredEnvKeys": [ + + ], + "parametersSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "조회할 의뢰서 진행상태" + }, + "period": { + "type": "string", + "description": "조회할 기간 ì¡°ê±´" + }, + "target": { + "type": "string", + "description": "나의 ì—\u0085무 또는 전체 조회대상" + } + }, + "additionalProperties": false + }, + "outputSchema": null, + "actionPrompts": { + + }, + "categoryKey": "sol", + "endpoint": "http://localhost:8084/mcp/sol_request_list", + "podUrl": "http://localhost:8084", + "visible": true, + "enabled": true, + "isRegistered": false, + "requiresApproval": false, + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true, + "integrationType": "REST", + "mciServiceId": "SOLG00000001", + "lastHeartbeat": null, + "failureRateThreshold": null, + "slidingWindowSize": null, + "rateLimitForPeriod": null, + "operationType": "READ", + "retryEnabled": true, + "circuitBreakerFailureThreshold": 0, + "circuitBreakerOpenMillis": 0, + "timeoutMillis": 5000 + } + ], + "Count": 12 +}