forked from kimhyungsik/ax_hub_mcp_tool
feat: refactor imports in ExecuteService and MCP Gateway items
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
32
fix.py
Normal 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!")
|
||||
13
payload.json
Normal file
13
payload.json
Normal 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
24
test.py
Normal 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)
|
||||
Reference in New Issue
Block a user