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