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 0000000..10bbb1a Binary files /dev/null and b/logs.txt differ diff --git a/payload.json b/payload.json new file mode 100644 index 0000000..bde3df6 --- /dev/null +++ b/payload.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "other_register_vacation", + "arguments": { + "date": "2026-07-15", + "employeeId": "010-9999-1111", + "unknownData": "이건 차단되어야 해" + } + } +} diff --git a/test.py b/test.py new file mode 100644 index 0000000..77daab4 --- /dev/null +++ b/test.py @@ -0,0 +1,24 @@ +import urllib.request +import json + +url = 'http://localhost:8281/mcp' +data = { + "jsonrpc": "2.0", + "id": "1", + "method": "tools/call", + "params": { + "name": "other_register_vacation", + "arguments": { + "date": "2026-07-15", + "employeeId": "010-9999-1111", + "unknownData": "이건 차단되어야 해" + } + } +} + +req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers={'Content-Type': 'application/json'}) +try: + with urllib.request.urlopen(req) as response: + print(response.read().decode('utf-8')) +except Exception as e: + print(e)