From f1ca0d6df17ba732319c9f1593c83b4a91104afd Mon Sep 17 00:00:00 2001 From: jade Date: Thu, 13 Aug 2026 16:37:30 +0900 Subject: [PATCH] refactor: remove redis and switch to in-memory, rollback incomplete Spring AI 2.0 upgrade --- .gitea/workflows/deploy.yml | 2 +- dap-gateway/build.gradle | 4 +- .../dap/mcg/config/McpGatewayProperties.java | 12 +- .../dap/mcg/config/RedisConfig.java | 46 - .../dap/mcg/presentation/ChatController.java | 4 +- .../mcg/presentation/McpRouterController.java | 20 +- .../dap/mcg/redis/RedisToolTraceService.java | 264 ------ .../mcg/registry/InMemoryRegistryService.java | 111 +++ .../mcg/registry/RedisRegistryService.java | 110 --- .../dap/mcg/service/ExecuteService.java | 20 +- .../dap/mcg/service/KillSwitchService.java | 29 +- .../dap/mcg/service/ToolPlanner.java | 46 +- .../mcg/sync/RegistryMcpToolSynchronizer.java | 10 +- .../mcg/trace/InMemoryToolTraceService.java | 172 ++++ .../McpMonitorEventService.java | 4 +- .../gateway/DapGatewayApplicationTests.java | 14 +- .../dap/mcg/DapGatewayApplicationTests.java | 1 + .../dap/lib/mcp/McpSdk2CompatibilityTest.java | 20 + deps.txt | Bin 0 -> 66962 bytes docker-compose.yml | 26 +- smp_tools.json | 859 ++++++++++++++++++ 21 files changed, 1243 insertions(+), 531 deletions(-) delete mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/config/RedisConfig.java delete mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/redis/RedisToolTraceService.java create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/InMemoryRegistryService.java delete mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/registry/RedisRegistryService.java create mode 100644 dap-gateway/src/main/java/io/shinhanlife/dap/mcg/trace/InMemoryToolTraceService.java rename dap-gateway/src/main/java/io/shinhanlife/dap/mcg/{redis => trace}/McpMonitorEventService.java (95%) create mode 100644 dap-gateway/src/test/java/io/shinhanlife/dap/mcg/DapGatewayApplicationTests.java create mode 100644 dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/McpSdk2CompatibilityTest.java create mode 100644 deps.txt create mode 100644 smp_tools.json 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 0000000000000000000000000000000000000000..6ac78b7ceea28b312ce65412b480ee187d0d6ed7 GIT binary patch literal 66962 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