From cdd61393fee462a9de1ee6958215e6a16431ea11 Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 14 Jul 2026 16:56:18 +0900 Subject: [PATCH] feat: refactor imports in ExecuteService and MCP Gateway items --- .../biz/mcp/gateway/dto/OperationType.java | 20 ++ .../biz/mcp/gateway/dto/ToolMetadata.java | 16 + .../gateway/redis/McpMonitorEventService.java | 40 +++ .../gateway/redis/RedisToolTraceService.java | 252 +++++++++++++++ .../mcp/gateway/resilience/RetryPolicy.java | 29 ++ .../security/ToolAuthorizationService.java | 55 ++++ .../mcp/gateway/service/ExecuteService.java | 304 ++++++++++++++---- fix.py | 32 ++ logs.txt | Bin 0 -> 595794 bytes payload.json | 13 + test.py | 24 ++ 11 files changed, 719 insertions(+), 66 deletions(-) create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/OperationType.java create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/McpMonitorEventService.java create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/RedisToolTraceService.java create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/RetryPolicy.java create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/ToolAuthorizationService.java create mode 100644 fix.py create mode 100644 logs.txt create mode 100644 payload.json create mode 100644 test.py diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/OperationType.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/OperationType.java new file mode 100644 index 0000000..fcaae79 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/OperationType.java @@ -0,0 +1,20 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.dto; + +/** + * @package io.shinhanlife.axhub.biz.mcp.gateway.dto + * @className OperationType + * @description AX HUB 시스템 처리 클래스 + * @author 김형식 + * @create 2026.09.01 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.09.01  김형식    최초생성
+ * 
+ * 
+ */ +public enum OperationType { + READ, + WRITE +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java index 3b094a6..e378ebe 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/dto/ToolMetadata.java @@ -85,6 +85,22 @@ public class ToolMetadata { private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수 private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수 + // 7. Gateway 코어 제어용 설정 필드 추가 (재시도, 타임아웃, 오퍼레이션 타입) + @Builder.Default + private OperationType operationType = OperationType.READ; + + @Builder.Default + private Boolean retryEnabled = true; + + @Builder.Default + private Integer circuitBreakerFailureThreshold = 0; + + @Builder.Default + private Long circuitBreakerOpenMillis = 0L; + + @Builder.Default + private Long timeoutMillis = 0L; + // --- Guardrail 호환성을 위한 메서드 추가 --- public java.util.Set allowedArguments() { if (parametersSchema == null || !parametersSchema.containsKey("properties")) return java.util.Set.of(); diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/McpMonitorEventService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/McpMonitorEventService.java new file mode 100644 index 0000000..7dd6d3f --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/McpMonitorEventService.java @@ -0,0 +1,40 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.redis; + +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class McpMonitorEventService { + private final List emitters = new CopyOnWriteArrayList<>(); + + /** + * MCP Monitor browser screen subscribes to this emitter. + */ + public SseEmitter subscribe() { + SseEmitter emitter = new SseEmitter(0L); + emitters.add(emitter); + emitter.onCompletion(() -> emitters.remove(emitter)); + emitter.onTimeout(() -> emitters.remove(emitter)); + emitter.onError(error -> emitters.remove(emitter)); + return emitter; + } + + /** + * Sends one monitor event only when an Agent tool request changes state inside MCP. + */ + public void publish(String payload) { + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("mcp-tool-trace") + .data(payload)); + } catch (IOException | IllegalStateException error) { + emitters.remove(emitter); + } + } + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/RedisToolTraceService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/RedisToolTraceService.java new file mode 100644 index 0000000..411bef8 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/redis/RedisToolTraceService.java @@ -0,0 +1,252 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.redis; + +import io.shinhanlife.axhub.biz.mcp.gateway.config.McpGatewayProperties; +import io.shinhanlife.axhub.biz.mcp.gateway.guardrail.SensitiveDataMasker; +import io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContext; +import io.shinhanlife.axhub.biz.mcp.gateway.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.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 SensitiveDataMasker masker; + private final Map attemptStates = new ConcurrentHashMap<>(); + + public RedisToolTraceService(McpGatewayProperties properties, + ObjectProvider redisProvider, + ObjectMapper json, + McpMonitorEventService monitorEvents, + SensitiveDataMasker masker) { + this.properties = properties; + this.redisProvider = redisProvider; + this.json = json; + this.monitorEvents = monitorEvents; + this.masker = masker; + } + + /** + * 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(""))); + + java.util.List argNames = new java.util.ArrayList<>(); + arguments.fieldNames().forEachRemaining(argNames::add); + trace.put("argumentNames", argNames); + + trace.put("arguments", masker.mask(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/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/RetryPolicy.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/RetryPolicy.java new file mode 100644 index 0000000..1d7b62d --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/RetryPolicy.java @@ -0,0 +1,29 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +/** + * Tool 호출 재시도 정책입니다. + * + * 최대 재시도 횟수, 최초 대기 시간, Backoff 배율, 최대 대기 시간을 담습니다. + */ +public record RetryPolicy(int maxAttempts, long initialBackoffMillis, double backoffMultiplier, long maxBackoffMillis) { + /** + * WRITE 요청처럼 재시도하면 위험한 경우 사용하는 비활성 정책입니다. + */ + public static RetryPolicy disabled() { + return new RetryPolicy(1, 0, 1.0, 0); + } + + /** + * 실패한 시도 횟수에 따라 다음 재시도 전 대기 시간을 계산합니다. + */ + public long backoffMillis(int failedAttempt) { + if (failedAttempt < 1 || initialBackoffMillis < 1) { + return 0; + } + double backoff = initialBackoffMillis * Math.pow(backoffMultiplier, failedAttempt - 1); + if (maxBackoffMillis > 0) { + backoff = Math.min(backoff, maxBackoffMillis); + } + return Math.max(0, Math.round(backoff)); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/ToolAuthorizationService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/ToolAuthorizationService.java new file mode 100644 index 0000000..a598a0c --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/ToolAuthorizationService.java @@ -0,0 +1,55 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.security; + +import io.shinhanlife.axhub.biz.mcp.gateway.config.McpGatewayProperties; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.ToolExecutionException; +import io.shinhanlife.axhub.biz.mcp.gateway.dto.OperationType; +import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata; +import org.springframework.stereotype.Service; + +import java.util.Set; + +@Service +/** + * Agent가 특정 Tool을 호출할 수 있는지 판단하는 권한 검증 서비스입니다. + * + * 서버 전체 허용 Tool, Agent가 보낸 allowed-tools 헤더, WRITE 승인 여부를 함께 확인합니다. + */ +public class ToolAuthorizationService { + private final McpGatewayProperties properties; + + public ToolAuthorizationService(McpGatewayProperties properties) { + this.properties = properties; + } + + /** + * Tool 실행 직전에 호출 가능 여부를 검사하고, 거부 시 ToolExecutionException을 발생시킵니다. + */ + public void authorize(McpRequestContext context, ToolMetadata metadata) { + if (properties.agentClaimsRequired() && context.allowedTools().isEmpty()) { + throw new ToolExecutionException(FailureType.AUTHORIZATION_ERROR, "Missing agent authorization claims"); + } + if (properties.trustedClaimsRequired() && !context.claimsTrusted()) { + throw new ToolExecutionException(FailureType.AUTHORIZATION_ERROR, "Agent authorization claims are not trusted"); + } + Set serverAllowedTools = properties.allowedToolSet(); + if (!isAllowed(serverAllowedTools, metadata.getName())) { + throw new ToolExecutionException(FailureType.AUTHORIZATION_ERROR, "Tool is not allowed by server policy: " + metadata.getName()); + } + if (!isAllowed(context.allowedTools(), metadata.getName())) { + throw new ToolExecutionException(FailureType.AUTHORIZATION_ERROR, "Tool is not allowed by agent claims: " + metadata.getName()); + } + if (properties.writeApprovalRequired() + && (metadata.getOperationType() == OperationType.WRITE || (metadata.getRequiresApproval() != null && metadata.getRequiresApproval())) + && !context.writeApproved()) { + throw new ToolExecutionException(FailureType.AUTHORIZATION_ERROR, "Write tool requires approval: " + metadata.getName()); + } + } + + /** + * 빈 목록은 제한 없음, "*"는 모든 Tool 허용을 의미합니다. + */ + private boolean isAllowed(Set allowedTools, String toolName) { + return allowedTools.isEmpty() || allowedTools.contains("*") || allowedTools.contains(toolName); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java index 7e55ba9..091f10e 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java @@ -1,30 +1,82 @@ package io.shinhanlife.axhub.biz.mcp.gateway.service; -import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params; import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata; -import java.util.List; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.RetryPolicy; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.ToolExecutionException; +import io.shinhanlife.axhub.biz.mcp.gateway.dto.OperationType; +import io.shinhanlife.axhub.biz.mcp.gateway.config.McpGatewayProperties; +import io.shinhanlife.axhub.biz.mcp.gateway.guardrail.SensitiveDataMasker; +import io.shinhanlife.axhub.biz.mcp.gateway.guardrail.GuardrailService; +import io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContext; +import io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContextResolver; +import io.shinhanlife.axhub.biz.mcp.gateway.audit.AuditLogService; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.CircuitBreaker; +import io.shinhanlife.axhub.biz.mcp.gateway.resilience.CircuitBreakerService; +import io.shinhanlife.axhub.biz.mcp.gateway.security.ToolAuthorizationService; +import io.shinhanlife.axhub.biz.mcp.gateway.redis.RedisToolTraceService; + +import jakarta.annotation.PreDestroy; import java.util.Map; import java.util.UUID; -import lombok.RequiredArgsConstructor; +import java.util.concurrent.*; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; @Slf4j @Service -@RequiredArgsConstructor public class ExecuteService { private final ToolPlanner planner; private final KillSwitchService killSwitchService; - private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; - private final io.shinhanlife.axhub.biz.mcp.gateway.guardrail.SensitiveDataMasker dataMasker; - private final io.shinhanlife.axhub.biz.mcp.gateway.guardrail.GuardrailService guardrailService; - private final io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContextResolver contextResolver; - private final io.shinhanlife.axhub.biz.mcp.gateway.audit.AuditLogService auditLogService; - private final io.shinhanlife.axhub.biz.mcp.gateway.resilience.CircuitBreakerService circuitBreakerService; + private final ObjectMapper objectMapper; + private final SensitiveDataMasker dataMasker; + private final GuardrailService guardrailService; + private final McpRequestContextResolver contextResolver; + private final AuditLogService auditLogService; + private final CircuitBreakerService circuitBreakerService; + private final ToolAuthorizationService authorizationService; + private final RedisToolTraceService redisTrace; + private final McpGatewayProperties properties; private final RestClient restClient = RestClient.create(); + private final ExecutorService executor; + + public ExecuteService(ToolPlanner planner, + KillSwitchService killSwitchService, + ObjectMapper objectMapper, + SensitiveDataMasker dataMasker, + GuardrailService guardrailService, + McpRequestContextResolver contextResolver, + AuditLogService auditLogService, + CircuitBreakerService circuitBreakerService, + ToolAuthorizationService authorizationService, + RedisToolTraceService redisTrace, + McpGatewayProperties properties) { + this.planner = planner; + this.killSwitchService = killSwitchService; + this.objectMapper = objectMapper; + this.dataMasker = dataMasker; + this.guardrailService = guardrailService; + this.contextResolver = contextResolver; + this.auditLogService = auditLogService; + this.circuitBreakerService = circuitBreakerService; + this.authorizationService = authorizationService; + this.redisTrace = redisTrace; + this.properties = properties; + + this.executor = new ThreadPoolExecutor( + properties.toolExecutorCorePoolSize(), + properties.toolExecutorMaxPoolSize(), + properties.toolExecutorKeepAliveSeconds(), + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(properties.toolExecutorQueueCapacity()), + new ThreadPoolExecutor.AbortPolicy() + ); + } public Object execute(Map payload, String tenantId) { log.info(" [ExecuteService] 전체 실행 흐름 제어 시작"); @@ -36,72 +88,144 @@ public class ExecuteService { killSwitchService.checkTool(toolName); var planObj = planner.createPlan(payload, tenantId); - ToolMetadata plan = (ToolMetadata) planObj; + ToolMetadata metadata = (ToolMetadata) planObj; - io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContext context = contextResolver.current(); + McpRequestContext context = contextResolver.current(); - // --- Guardrail 검증 (Admin UI 및 AI 요청 모두 적용) --- - com.fasterxml.jackson.databind.node.ObjectNode argumentsNode = objectMapper.createObjectNode(); + ObjectNode argumentsNode = objectMapper.createObjectNode(); if (params != null && params.containsKey("arguments")) { argumentsNode = objectMapper.valueToTree(params.get("arguments")); - guardrailService.validate(plan, argumentsNode); } + long startedAt = System.nanoTime(); auditLogService.toolStarted(context, toolName, argumentsNode); + redisTrace.started(context, metadata, argumentsNode); - if (plan.getIntegrationType() != null) { - killSwitchService.checkRoute(plan.getIntegrationType()); - } - - io.shinhanlife.axhub.biz.mcp.gateway.resilience.CircuitBreaker circuitBreaker = circuitBreakerService.breaker("tool:" + toolName, 0, 0); - circuitBreaker.beforeCall(); - - // Dynamic Routing: Find the target Pod from the Redis registry - String targetUrl = "http://localhost:8084"; // Fallback (tool-other) - if (plan.getPodUrl() != null && !plan.getPodUrl().isEmpty()) { - targetUrl = plan.getPodUrl(); - } - - log.info(" [ExecuteService] 라우팅 목적지: {}", targetUrl); - String executeApiUrl = targetUrl + "/mcp/api/v1/tools/call"; - - long startTime = System.currentTimeMillis(); - boolean success = false; - String errorCode = null; - - // Forward the request to the target tool pod using RestClient with fallback try { - try { - log.info(" [ExecuteService] 요청 페이로드(마스킹 적용): {}", objectMapper.writeValueAsString(dataMasker.mask(objectMapper.valueToTree(payload)))); - } catch (Exception ignore) {} - - Object result = executeWithUrl(payload, executeApiUrl); - circuitBreaker.recordSuccess(); - success = true; - return result; - } catch (Exception e) { - errorCode = io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR.name(); - circuitBreaker.recordFailure(io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR); - if (executeApiUrl.contains("http://tool-")) { - String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); - log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl); - try { - Object result = executeWithUrl(payload, fallbackUrl); - circuitBreaker.recordSuccess(); - success = true; - errorCode = null; - return result; - } catch (Exception ex) { - circuitBreaker.recordFailure(io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR); - log.error(" [ExecuteService] localhost 재시도 실패: {}", ex.getMessage()); - throw new io.shinhanlife.axhub.biz.mcp.gateway.resilience.ToolExecutionException(io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); - } + authorizationService.authorize(context, metadata); + if (params != null && params.containsKey("arguments")) { + guardrailService.validate(metadata, argumentsNode); } - log.error(" [ExecuteService] Tool Pod 호출 실패: {}", e.getMessage()); - throw new io.shinhanlife.axhub.biz.mcp.gateway.resilience.ToolExecutionException(io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR, "Tool Pod 호출 실패: " + e.getMessage()); - } finally { - long elapsedMillis = System.currentTimeMillis() - startTime; - auditLogService.toolFinished(context, toolName, elapsedMillis, success, errorCode); + + if (metadata.getIntegrationType() != null) { + killSwitchService.checkRoute(metadata.getIntegrationType()); + } + + Object result = executeWithResilience(context, metadata, argumentsNode, payload); + long elapsedMillis = elapsedMillis(startedAt); + + String responseText = ""; + try { responseText = objectMapper.writeValueAsString(result); } catch (Exception ignore) {} + + auditLogService.toolFinished(context, metadata.getName(), elapsedMillis, true, ""); + redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText); + + return result; + } 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(), ""); + throw error; + } 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(), ""); + throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool execution failed: " + toolName, error); + } + } + + private Object executeWithResilience(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, Map payload) { + RetryPolicy retryPolicy = retryPolicy(metadata, arguments); + CircuitBreaker circuitBreaker = circuitBreakerService.breaker("tool:" + metadata.getName(), + metadata.getCircuitBreakerFailureThreshold(), + metadata.getCircuitBreakerOpenMillis()); + + ToolExecutionException lastError = null; + for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) { + try { + redisTrace.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts()); + circuitBreaker.beforeCall(); + Object result = executeOnce(metadata, payload); + circuitBreaker.recordSuccess(); + return result; + } catch (ToolExecutionException error) { + lastError = error; + circuitBreaker.recordFailure(error.failureType()); + if (!shouldRetry(metadata, arguments, error.failureType(), attempt, retryPolicy)) { + throw error; + } + long backoffMillis = retryPolicy.backoffMillis(attempt); + redisTrace.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis, + error.failureType().name()); + sleepBeforeRetry(metadata.getName(), attempt, backoffMillis, error.failureType()); + } + } + throw lastError == null + ? new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool execution failed: " + metadata.getName()) + : lastError; + } + + private Object executeOnce(ToolMetadata metadata, Map payload) { + CompletableFuture future; + + try { + future = CompletableFuture.supplyAsync(() -> { + try { + String targetUrl = "http://localhost:8084"; // Fallback + if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) { + targetUrl = metadata.getPodUrl(); + } + String executeApiUrl = targetUrl + "/mcp/api/v1/tools/call"; + + try { + log.info(" [ExecuteService] 요청 페이로드(마스킹 적용): {}", objectMapper.writeValueAsString(dataMasker.mask(objectMapper.valueToTree(payload)))); + } catch (Exception ignore) {} + + try { + return executeWithUrl(payload, executeApiUrl); + } catch (Exception e) { + if (executeApiUrl.contains("http://tool-")) { + String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); + log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl); + try { + return executeWithUrl(payload, fallbackUrl); + } catch (Exception ex) { + throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); + } + } + throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패: " + e.getMessage()); + } + } catch (ToolExecutionException error) { + throw error; + } catch (Exception error) { + throw new ToolExecutionException( + FailureType.INTERNAL_ERROR, + "Tool execution failed: " + metadata.getName(), + error + ); + } + }, executor); + } catch (RuntimeException error) { + throw new ToolExecutionException( + FailureType.SERVER_ERROR, + "MCP Tool 실행 큐가 가득 찼습니다: " + metadata.getName(), + error + ); + } + + try { + return future.get(timeoutMillis(metadata), TimeUnit.MILLISECONDS); + } catch (TimeoutException error) { + future.cancel(true); + throw new ToolExecutionException(FailureType.TIMEOUT, "Tool execution timed out: " + metadata.getName(), error); + } catch (ExecutionException error) { + if (error.getCause() instanceof ToolExecutionException toolError) { + throw toolError; + } + throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool execution failed: " + metadata.getName(), error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool 실행이 중단되었습니다: " + metadata.getName(), error); } } @@ -115,4 +239,52 @@ public class ExecuteService { .retrieve() .body(Object.class); } + + private RetryPolicy retryPolicy(ToolMetadata metadata, ObjectNode arguments) { + if (!retryAllowedByOperation(metadata, arguments)) { + return RetryPolicy.disabled(); + } + return new RetryPolicy(properties.retryMaxAttempts(), properties.retryInitialBackoffMillis(), + properties.retryBackoffMultiplier(), properties.retryMaxBackoffMillis()); + } + + private boolean retryAllowedByOperation(ToolMetadata metadata, ObjectNode arguments) { + if (metadata.getOperationType() == OperationType.READ) { + return metadata.getRetryEnabled() != null ? metadata.getRetryEnabled() : true; + } + return arguments.hasNonNull("idempotencyKey") && !arguments.path("idempotencyKey").asText("").isBlank(); + } + + private boolean shouldRetry(ToolMetadata metadata, ObjectNode arguments, FailureType failureType, int attempt, RetryPolicy retryPolicy) { + if (attempt >= retryPolicy.maxAttempts() || !retryAllowedByOperation(metadata, arguments)) { + return false; + } + return failureType == FailureType.TIMEOUT || failureType == FailureType.NETWORK_ERROR || failureType == FailureType.SERVER_ERROR; + } + + private void sleepBeforeRetry(String toolName, int attempt, long backoffMillis, FailureType failureType) { + if (backoffMillis <= 0) { + return; + } + log.warn("Retrying tool call. tool={} failedAttempt={} failureType={} backoffMillis={}", toolName, attempt, failureType, backoffMillis); + try { + Thread.sleep(backoffMillis); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool 재시도가 중단되었습니다: " + toolName, error); + } + } + + private long timeoutMillis(ToolMetadata metadata) { + return (metadata.getTimeoutMillis() != null && metadata.getTimeoutMillis() > 0) ? metadata.getTimeoutMillis() : properties.toolTimeoutMillis(); + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + + @PreDestroy + public void shutdown() { + executor.shutdownNow(); + } } \ No newline at end of file diff --git a/fix.py b/fix.py new file mode 100644 index 0000000..73dba37 --- /dev/null +++ b/fix.py @@ -0,0 +1,32 @@ +import os + +# Fix ToolAuthorizationService +auth_path = r"C:\eGovFrameDev-4.3.1-64bit\workspace-egov\axhub-backend-main\axhub-gateway\src\main\java\io\shinhanlife\axhub\biz\mcp\gateway\security\ToolAuthorizationService.java" +with open(auth_path, "r", encoding="utf-8") as f: + auth_content = f.read() + +auth_content = auth_content.replace("metadata.name()", "metadata.getName()") +auth_content = auth_content.replace("metadata.operationType()", "metadata.getOperationType()") +auth_content = auth_content.replace("metadata.requiresApproval()", "(metadata.getRequiresApproval() != null && metadata.getRequiresApproval())") + +with open(auth_path, "w", encoding="utf-8") as f: + f.write(auth_content) + +# Fix RedisToolTraceService +redis_path = r"C:\eGovFrameDev-4.3.1-64bit\workspace-egov\axhub-backend-main\axhub-gateway\src\main\java\io\shinhanlife\axhub\biz\mcp\gateway\redis\RedisToolTraceService.java" +with open(redis_path, "r", encoding="utf-8") as f: + redis_content = f.read() + +redis_content = redis_content.replace("metadata.name()", "metadata.getName()") +redis_content = redis_content.replace("metadata.operationType()", "metadata.getOperationType()") + +arg_names_old = 'trace.put("argumentNames", arguments.propertyNames());' +arg_names_new = ''' java.util.List argNames = new java.util.ArrayList<>(); + arguments.fieldNames().forEachRemaining(argNames::add); + trace.put("argumentNames", argNames);''' +redis_content = redis_content.replace(arg_names_old, arg_names_new) + +with open(redis_path, "w", encoding="utf-8") as f: + f.write(redis_content) + +print("Files fixed!") diff --git a/logs.txt b/logs.txt new file mode 100644 index 0000000000000000000000000000000000000000..10bbb1a54588b690c170dd1f5101ae4c30fe7f22 GIT binary patch literal 595794 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