feat: refactor imports in ExecuteService and MCP Gateway items

This commit is contained in:
jade
2026-07-14 16:56:18 +09:00
parent 1eddc1eb36
commit cdd61393fe
11 changed files with 719 additions and 66 deletions

View File

@@ -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
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 김형식 최초생성
*
* </pre>
*/
public enum OperationType {
READ,
WRITE
}

View File

@@ -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<String> allowedArguments() {
if (parametersSchema == null || !parametersSchema.containsKey("properties")) return java.util.Set.of();

View File

@@ -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<SseEmitter> 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);
}
}
}
}

View File

@@ -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<StringRedisTemplate> redisProvider;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final SensitiveDataMasker masker;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
public RedisToolTraceService(McpGatewayProperties properties,
ObjectProvider<StringRedisTemplate> 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<String> 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<String> 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<String> 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<String, Object> 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<String> 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<String, Object> responseSummary(String responseText) {
Map<String, Object> 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) {
}
}

View File

@@ -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));
}
}

View File

@@ -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<String> 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<String> allowedTools, String toolName) {
return allowedTools.isEmpty() || allowedTools.contains("*") || allowedTools.contains(toolName);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> payload) {
CompletableFuture<Object> 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();
}
}

32
fix.py Normal file
View File

@@ -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<String> 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!")

BIN
logs.txt Normal file

Binary file not shown.

13
payload.json Normal file
View File

@@ -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": "이건 차단되어야 해"
}
}
}

24
test.py Normal file
View File

@@ -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)