diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/audit/AuditLogService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/audit/AuditLogService.java new file mode 100644 index 0000000..c239e75 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/audit/AuditLogService.java @@ -0,0 +1,48 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.audit; + +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 com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +/** + * MCP Tool 호출 이력을 감사 로그로 남기는 서비스입니다. + * + * 금융권 환경에서 누가, 어떤 Agent로, 어떤 Tool을 호출했는지 추적하기 위한 로그를 담당합니다. + */ +public class AuditLogService { + private static final Logger audit = LoggerFactory.getLogger("MCP_AUDIT"); + private final McpGatewayProperties properties; + private final SensitiveDataMasker masker; + + public AuditLogService(McpGatewayProperties properties, SensitiveDataMasker masker) { + this.properties = properties; + this.masker = masker; + } + + /** + * Tool 실행 시작 시점에 요청자와 마스킹된 인자를 기록합니다. + */ + public void toolStarted(McpRequestContext context, String toolName, JsonNode arguments) { + if (!properties.auditEnabled()) { + return; + } + audit.info("event=tool_started requestId={} agentId={} userId={} clientAddress={} tool={} arguments={}", + context.requestId(), context.agentId(), context.userId(), context.clientAddress(), toolName, masker.mask(arguments)); + } + + /** + * Tool 실행 종료 시점에 성공 여부, 처리 시간, 오류 유형을 기록합니다. + */ + public void toolFinished(McpRequestContext context, String toolName, long elapsedMillis, boolean success, String errorCode) { + if (!properties.auditEnabled()) { + return; + } + audit.info("event=tool_finished requestId={} agentId={} userId={} clientAddress={} tool={} elapsedMillis={} success={} errorCode={}", + context.requestId(), context.agentId(), context.userId(), context.clientAddress(), toolName, elapsedMillis, success, errorCode); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/McpGatewayProperties.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/McpGatewayProperties.java new file mode 100644 index 0000000..eaac6d9 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/config/McpGatewayProperties.java @@ -0,0 +1,130 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@ConfigurationProperties(prefix = "mcp") +/** + * application.properties의 mcp.* 설정을 Java 코드에서 사용하기 위한 설정 클래스입니다. + * + * API Key, 허용 Tool, 감사 로그, Redis Trace, Retry, Circuit Breaker, + * EIMS/Tool 서버 주소 같은 MCP Gateway 운영 설정을 한 곳에서 관리합니다. + */ +public class McpGatewayProperties { + private String apiKey; + private List allowedTools; + private Boolean auditEnabled; + private Boolean agentClaimsRequired; + private Boolean trustedClaimsRequired; + private Boolean writeApprovalRequired; + private Boolean redisTraceEnabled; + private Long redisTraceTtlSeconds; + private Long toolTimeoutMillis; + private Integer retryMaxAttempts; + private Long retryInitialBackoffMillis; + private Double retryBackoffMultiplier; + private Long retryMaxBackoffMillis; + private Integer circuitBreakerFailureThreshold; + private Long circuitBreakerOpenMillis; + private String eimsBaseUrl; + private Long eimsTimeoutMillis; + private String toolServerBaseUrl; + private String toolServerApiKey; + private Long toolHeartbeatTimeoutSeconds; + private Integer defaultPageSize; + private Integer maxPageSize; + private Integer maxPagesPerCall; + private Long maxStreamBytes; + private Long maxDurationSeconds; + private Long maxResponseBytesFromTool; + private Long largeResponseMaxItemBytes; + private Integer largeResponseMaxPreviewItems; + private Integer toolExecutorCorePoolSize; + private Integer toolExecutorMaxPoolSize; + private Integer toolExecutorQueueCapacity; + private Long toolExecutorKeepAliveSeconds; + + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + public void setAllowedTools(List allowedTools) { this.allowedTools = allowedTools; } + public void setAuditEnabled(Boolean auditEnabled) { this.auditEnabled = auditEnabled; } + public void setAgentClaimsRequired(Boolean agentClaimsRequired) { this.agentClaimsRequired = agentClaimsRequired; } + public void setTrustedClaimsRequired(Boolean trustedClaimsRequired) { this.trustedClaimsRequired = trustedClaimsRequired; } + public void setWriteApprovalRequired(Boolean writeApprovalRequired) { this.writeApprovalRequired = writeApprovalRequired; } + public void setRedisTraceEnabled(Boolean redisTraceEnabled) { this.redisTraceEnabled = redisTraceEnabled; } + public void setRedisTraceTtlSeconds(Long redisTraceTtlSeconds) { this.redisTraceTtlSeconds = redisTraceTtlSeconds; } + public void setToolTimeoutMillis(Long toolTimeoutMillis) { this.toolTimeoutMillis = toolTimeoutMillis; } + public void setRetryMaxAttempts(Integer retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; } + public void setRetryInitialBackoffMillis(Long retryInitialBackoffMillis) { this.retryInitialBackoffMillis = retryInitialBackoffMillis; } + public void setRetryBackoffMultiplier(Double retryBackoffMultiplier) { this.retryBackoffMultiplier = retryBackoffMultiplier; } + public void setRetryMaxBackoffMillis(Long retryMaxBackoffMillis) { this.retryMaxBackoffMillis = retryMaxBackoffMillis; } + public void setCircuitBreakerFailureThreshold(Integer circuitBreakerFailureThreshold) { this.circuitBreakerFailureThreshold = circuitBreakerFailureThreshold; } + public void setCircuitBreakerOpenMillis(Long circuitBreakerOpenMillis) { this.circuitBreakerOpenMillis = circuitBreakerOpenMillis; } + public void setEimsBaseUrl(String eimsBaseUrl) { this.eimsBaseUrl = eimsBaseUrl; } + public void setEimsTimeoutMillis(Long eimsTimeoutMillis) { this.eimsTimeoutMillis = eimsTimeoutMillis; } + public void setToolServerBaseUrl(String toolServerBaseUrl) { this.toolServerBaseUrl = toolServerBaseUrl; } + public void setToolServerApiKey(String toolServerApiKey) { this.toolServerApiKey = toolServerApiKey; } + public void setToolHeartbeatTimeoutSeconds(Long toolHeartbeatTimeoutSeconds) { this.toolHeartbeatTimeoutSeconds = toolHeartbeatTimeoutSeconds; } + public void setDefaultPageSize(Integer defaultPageSize) { this.defaultPageSize = defaultPageSize; } + public void setMaxPageSize(Integer maxPageSize) { this.maxPageSize = maxPageSize; } + public void setMaxPagesPerCall(Integer maxPagesPerCall) { this.maxPagesPerCall = maxPagesPerCall; } + public void setMaxStreamBytes(Long maxStreamBytes) { this.maxStreamBytes = maxStreamBytes; } + public void setMaxDurationSeconds(Long maxDurationSeconds) { this.maxDurationSeconds = maxDurationSeconds; } + public void setMaxResponseBytesFromTool(Long maxResponseBytesFromTool) { this.maxResponseBytesFromTool = maxResponseBytesFromTool; } + public void setLargeResponseMaxItemBytes(Long largeResponseMaxItemBytes) { this.largeResponseMaxItemBytes = largeResponseMaxItemBytes; } + public void setLargeResponseMaxPreviewItems(Integer largeResponseMaxPreviewItems) { this.largeResponseMaxPreviewItems = largeResponseMaxPreviewItems; } + public void setToolExecutorCorePoolSize(Integer toolExecutorCorePoolSize) { this.toolExecutorCorePoolSize = toolExecutorCorePoolSize; } + public void setToolExecutorMaxPoolSize(Integer toolExecutorMaxPoolSize) { this.toolExecutorMaxPoolSize = toolExecutorMaxPoolSize; } + public void setToolExecutorQueueCapacity(Integer toolExecutorQueueCapacity) { this.toolExecutorQueueCapacity = toolExecutorQueueCapacity; } + public void setToolExecutorKeepAliveSeconds(Long toolExecutorKeepAliveSeconds) { this.toolExecutorKeepAliveSeconds = toolExecutorKeepAliveSeconds; } + + public String apiKey() { return apiKey; } + public boolean apiKeyEnabled() { return apiKey != null && !apiKey.isBlank(); } + public boolean auditEnabled() { return auditEnabled == null || auditEnabled; } + public boolean agentClaimsRequired() { return agentClaimsRequired == null || agentClaimsRequired; } + public boolean trustedClaimsRequired() { return trustedClaimsRequired == null || trustedClaimsRequired; } + public boolean writeApprovalRequired() { return writeApprovalRequired == null || writeApprovalRequired; } + public boolean redisTraceEnabled() { return redisTraceEnabled == null || redisTraceEnabled; } + public long redisTraceTtlSeconds() { return redisTraceTtlSeconds == null || redisTraceTtlSeconds < 1 ? 3_600 : redisTraceTtlSeconds; } + public long toolTimeoutMillis() { return toolTimeoutMillis == null || toolTimeoutMillis < 1 ? 5_000 : toolTimeoutMillis; } + public int retryMaxAttempts() { return retryMaxAttempts == null || retryMaxAttempts < 1 ? 3 : retryMaxAttempts; } + public long retryInitialBackoffMillis() { return retryInitialBackoffMillis == null || retryInitialBackoffMillis < 1 ? 1_000 : retryInitialBackoffMillis; } + public double retryBackoffMultiplier() { return retryBackoffMultiplier == null || retryBackoffMultiplier < 1.0 ? 2.0 : retryBackoffMultiplier; } + public long retryMaxBackoffMillis() { return retryMaxBackoffMillis == null || retryMaxBackoffMillis < 1 ? 5_000 : retryMaxBackoffMillis; } + public int circuitBreakerFailureThreshold() { return circuitBreakerFailureThreshold == null || circuitBreakerFailureThreshold < 1 ? 5 : circuitBreakerFailureThreshold; } + public long circuitBreakerOpenMillis() { return circuitBreakerOpenMillis == null || circuitBreakerOpenMillis < 1 ? 30_000 : circuitBreakerOpenMillis; } + public String eimsBaseUrl() { return eimsBaseUrl == null ? "" : eimsBaseUrl.trim(); } + public long eimsTimeoutMillis() { return eimsTimeoutMillis == null || eimsTimeoutMillis < 1 ? 3_000 : eimsTimeoutMillis; } + public String toolServerBaseUrl() { return toolServerBaseUrl == null || toolServerBaseUrl.isBlank() ? "http://localhost:9090" : toolServerBaseUrl.trim(); } + public String toolServerApiKey() { return toolServerApiKey == null ? "" : toolServerApiKey.trim(); } + public long toolHeartbeatTimeoutSeconds() { return toolHeartbeatTimeoutSeconds == null || toolHeartbeatTimeoutSeconds < 1 ? 30 : toolHeartbeatTimeoutSeconds; } + public int defaultPageSize() { return defaultPageSize == null || defaultPageSize < 1 ? 100 : defaultPageSize; } + public int maxPageSize() { return maxPageSize == null || maxPageSize < 1 ? 500 : maxPageSize; } + public int maxPagesPerCall() { return maxPagesPerCall == null || maxPagesPerCall < 1 ? 3 : maxPagesPerCall; } + public long maxStreamBytes() { return maxStreamBytes == null || maxStreamBytes < 1 ? 1_048_576 : maxStreamBytes; } + public long maxDurationSeconds() { return maxDurationSeconds == null || maxDurationSeconds < 1 ? 10 : maxDurationSeconds; } + public long maxResponseBytesFromTool() { return maxResponseBytesFromTool == null || maxResponseBytesFromTool < 1 ? 5_242_880 : maxResponseBytesFromTool; } + public long largeResponseMaxItemBytes() { return largeResponseMaxItemBytes == null || largeResponseMaxItemBytes < 1 ? 16_384 : largeResponseMaxItemBytes; } + public int largeResponseMaxPreviewItems() { return largeResponseMaxPreviewItems == null || largeResponseMaxPreviewItems < 1 ? 100 : largeResponseMaxPreviewItems; } + public int toolExecutorCorePoolSize() { return toolExecutorCorePoolSize == null || toolExecutorCorePoolSize < 1 ? 20 : toolExecutorCorePoolSize; } + public int toolExecutorMaxPoolSize() { + int core = toolExecutorCorePoolSize(); + return toolExecutorMaxPoolSize == null || toolExecutorMaxPoolSize < core ? Math.max(core, 100) : toolExecutorMaxPoolSize; + } + public int toolExecutorQueueCapacity() { return toolExecutorQueueCapacity == null || toolExecutorQueueCapacity < 1 ? 200 : toolExecutorQueueCapacity; } + public long toolExecutorKeepAliveSeconds() { return toolExecutorKeepAliveSeconds == null || toolExecutorKeepAliveSeconds < 1 ? 60 : toolExecutorKeepAliveSeconds; } + + public Set allowedToolSet() { + if (allowedTools == null) { + return Set.of(); + } + return allowedTools.stream() + .filter(tool -> tool != null && !tool.isBlank()) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } +} 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 a1c8be3..3b094a6 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 @@ -84,4 +84,15 @@ public class ToolMetadata { private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%) private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수 private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수 + + // --- Guardrail 호환성을 위한 메서드 추가 --- + public java.util.Set allowedArguments() { + if (parametersSchema == null || !parametersSchema.containsKey("properties")) return java.util.Set.of(); + return ((Map) parametersSchema.get("properties")).keySet(); + } + + public java.util.Set requiredArguments() { + if (parametersSchema == null || !parametersSchema.containsKey("required")) return java.util.Set.of(); + return new java.util.HashSet<>((java.util.List) parametersSchema.get("required")); + } } \ No newline at end of file diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/GuardrailService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/GuardrailService.java new file mode 100644 index 0000000..61db40a --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/GuardrailService.java @@ -0,0 +1,44 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.guardrail; + +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.ToolMetadata; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Service; + +import java.util.Iterator; + +@Service +/** + * Tool 호출 인자의 규격을 검증하는 Guardrail 서비스입니다. + * + * 필수값 누락, 허용되지 않은 인자 전달 같은 문제를 Tool 서버 호출 전에 차단합니다. + */ +public class GuardrailService { + /** + * ToolMetadata에 정의된 required/allowed arguments 기준으로 요청 인자를 검증합니다. + */ + public void validate(ToolMetadata metadata, ObjectNode arguments) { + Iterator fieldNames = arguments.fieldNames(); + while (fieldNames.hasNext()) { + String fieldName = fieldNames.next(); + if (!metadata.allowedArguments().contains(fieldName)) { + throw new ToolExecutionException(FailureType.CLIENT_ERROR, "Unknown argument for " + metadata.getName() + ": " + fieldName); + } + } + for (String requiredArgument : metadata.requiredArguments()) { + JsonNode value = arguments.get(requiredArgument); + if (isMissing(value)) { + throw new ToolExecutionException(FailureType.CLIENT_ERROR, "Missing required argument for " + metadata.getName() + ": " + requiredArgument); + } + } + } + + private boolean isMissing(JsonNode value) { + if (value == null || value.isNull() || value.isMissingNode()) { + return true; + } + return value.isTextual() && value.asText("").isBlank(); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/SensitiveDataMasker.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/SensitiveDataMasker.java new file mode 100644 index 0000000..1370541 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/SensitiveDataMasker.java @@ -0,0 +1,77 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.guardrail; + +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Audit Log, Redis Trace, Agent 응답 preview에 남으면 안 되는 민감정보를 마스킹합니다. + */ +@Component +public class SensitiveDataMasker { + private static final Set SENSITIVE_KEYS = Set.of( + "password", "passwd", "pwd", "token", "accessToken", "refreshToken", "secret", + "ssn", "rrn", "residentNumber", "cardNumber", "accountNumber", "accountNo", + "phone", "mobile", "email", "idempotencyKey"); + private static final Pattern EMAIL = Pattern.compile("([a-zA-Z0-9._%+-]{2})[a-zA-Z0-9._%+-]*(@[a-zA-Z0-9.-]+)"); + private static final Pattern CARD_OR_ACCOUNT = Pattern.compile("\\b(\\d{4})\\d{4,12}(\\d{2,4})\\b"); + private final ObjectMapper json; + + public SensitiveDataMasker(ObjectMapper json) { + this.json = json; + } + + /** + * JsonNode 전체를 재귀적으로 순회하며 민감 key와 민감 패턴을 마스킹합니다. + */ + public JsonNode mask(JsonNode input) { + if (input == null || input.isMissingNode() || input.isNull()) { + return json.createObjectNode(); + } + if (input.isArray()) { + ArrayNode masked = json.createArrayNode(); + for (JsonNode item : input) { + masked.add(mask(item)); + } + return masked; + } + if (input.isObject()) { + ObjectNode masked = json.createObjectNode(); + Iterator> fields = input.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String key = entry.getKey(); + JsonNode value = entry.getValue(); + if (isSensitiveKey(key)) { + masked.put(key, "***"); + } else { + masked.set(key, mask(value)); + } + } + return masked; + } + if (input.isTextual()) { + return json.valueToTree(maskText(input.asText())); + } + return input; + } + + private boolean isSensitiveKey(String key) { + return key != null && SENSITIVE_KEYS.stream().anyMatch(sensitive -> sensitive.equalsIgnoreCase(key)); + } + + private String maskText(String value) { + if (value == null || value.isBlank()) { + return value; + } + String masked = EMAIL.matcher(value).replaceAll("$1***$2"); + return CARD_OR_ACCOUNT.matcher(masked).replaceAll("$1********$2"); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreaker.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreaker.java new file mode 100644 index 0000000..b099ae1 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreaker.java @@ -0,0 +1,134 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +import java.time.Clock; +import java.time.Instant; + +/** + * Tool별 장애 확산을 막기 위한 Circuit Breaker입니다. + * + * 현재 OPEN 조건은 사용자가 요청한 기준에 맞춰 + * TIMEOUT 3회 이상 또는 SERVER_ERROR(5xx) 3회 이상입니다. + */ +public class CircuitBreaker { + private static final int TIMEOUT_OPEN_THRESHOLD = 3; + private static final int SERVER_ERROR_OPEN_THRESHOLD = 3; + + private final String name; + private final long openDurationMillis; + private final Clock clock; + private CircuitBreakerState state = CircuitBreakerState.CLOSED; + private int timeoutFailures; + private int serverErrorFailures; + private FailureType lastFailureType; + private String openedReason = ""; + private Instant openedAt; + + public CircuitBreaker(String name, int failureThreshold, long openDurationMillis, Clock clock) { + this.name = name; + this.openDurationMillis = Math.max(1, openDurationMillis); + this.clock = clock; + } + + /** + * 실제 Tool 호출 전에 현재 회로가 호출 가능한 상태인지 확인합니다. + */ + public synchronized void beforeCall() { + if (state != CircuitBreakerState.OPEN) { + return; + } + long openedMillis = openedAt == null ? 0 : clock.millis() - openedAt.toEpochMilli(); + if (openedMillis >= openDurationMillis) { + state = CircuitBreakerState.HALF_OPEN; + return; + } + throw new ToolExecutionException(FailureType.CIRCUIT_OPEN, "Circuit breaker is open: " + name); + } + + /** + * 호출 성공 시 실패 카운터를 초기화하고 CLOSED 상태로 복구합니다. + */ + public synchronized void recordSuccess() { + timeoutFailures = 0; + serverErrorFailures = 0; + lastFailureType = null; + openedReason = ""; + openedAt = null; + state = CircuitBreakerState.CLOSED; + } + + /** + * 장애 유형별 실패 횟수를 집계하고 조건에 맞으면 OPEN 상태로 전환합니다. + */ + public synchronized void recordFailure(FailureType failureType) { + if (failureType == FailureType.TIMEOUT) { + timeoutFailures++; + lastFailureType = failureType; + } else if (failureType == FailureType.SERVER_ERROR) { + serverErrorFailures++; + lastFailureType = failureType; + } else if (state == CircuitBreakerState.HALF_OPEN && isCircuitBreakerFailure(failureType)) { + lastFailureType = failureType; + open("HALF_OPEN_TEST_FAILED"); + return; + } else { + return; + } + + if (state == CircuitBreakerState.HALF_OPEN) { + open(failureType.name() + "_IN_HALF_OPEN"); + return; + } + if (timeoutFailures >= TIMEOUT_OPEN_THRESHOLD) { + open("TIMEOUT_3_OR_MORE"); + } + if (serverErrorFailures >= SERVER_ERROR_OPEN_THRESHOLD) { + open("SERVER_ERROR_5XX_3_OR_MORE"); + } + } + + public synchronized CircuitBreakerState state() { + return state; + } + + /** + * MCP Monitor 화면에 표시할 현재 상태 스냅샷입니다. + */ + public synchronized CircuitBreakerSnapshot snapshot() { + long remainingOpenMillis = 0; + if (state == CircuitBreakerState.OPEN && openedAt != null) { + long elapsed = Math.max(0, clock.millis() - openedAt.toEpochMilli()); + remainingOpenMillis = Math.max(0, openDurationMillis - elapsed); + } + return new CircuitBreakerSnapshot( + name, + state.name(), + timeoutFailures, + serverErrorFailures, + TIMEOUT_OPEN_THRESHOLD, + SERVER_ERROR_OPEN_THRESHOLD, + lastFailureType == null ? "" : lastFailureType.name(), + openedReason, + openedAt == null ? "" : openedAt.toString(), + remainingOpenMillis); + } + + /** + * 테스트 후 화면에서 회로를 수동 초기화할 때 사용합니다. + */ + public synchronized void reset() { + recordSuccess(); + } + + private void open(String reason) { + state = CircuitBreakerState.OPEN; + openedAt = Instant.now(clock); + openedReason = reason; + } + + private boolean isCircuitBreakerFailure(FailureType failureType) { + return failureType == FailureType.TIMEOUT + || failureType == FailureType.NETWORK_ERROR + || failureType == FailureType.SERVER_ERROR + || failureType == FailureType.INTERNAL_ERROR; + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerMonitorController.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerMonitorController.java new file mode 100644 index 0000000..f042619 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerMonitorController.java @@ -0,0 +1,39 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * MCP Monitor 화면에서 Circuit Breaker 상태를 확인하고 초기화하기 위한 API입니다. + */ +@RestController +@RequestMapping("/internal/circuit-breakers") +public class CircuitBreakerMonitorController { + private final CircuitBreakerService circuitBreakers; + + public CircuitBreakerMonitorController(CircuitBreakerService circuitBreakers) { + this.circuitBreakers = circuitBreakers; + } + + /** + * Tool별 Circuit Breaker 상태 목록을 반환합니다. + */ + @GetMapping + public List snapshots() { + return circuitBreakers.snapshots(); + } + + /** + * 모든 Circuit Breaker를 CLOSED 상태로 초기화합니다. + */ + @PostMapping("/reset") + public Map reset() { + circuitBreakers.resetAll(); + return Map.of("reset", true, "items", circuitBreakers.snapshots()); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerService.java new file mode 100644 index 0000000..59fb14f --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerService.java @@ -0,0 +1,71 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +import io.shinhanlife.axhub.biz.mcp.gateway.config.McpGatewayProperties; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +/** + * Tool 이름별 Circuit Breaker 인스턴스를 관리하는 서비스입니다. + * + * registry에 등록된 Tool별로 장애 상태를 독립적으로 관리합니다. + */ +public class CircuitBreakerService { + private final McpGatewayProperties properties; + private final Map breakers = new ConcurrentHashMap<>(); + + public CircuitBreakerService(McpGatewayProperties properties) { + this.properties = properties; + } + + /** + * 이름에 해당하는 Circuit Breaker를 반환하고, 없으면 새로 생성합니다. + */ + public CircuitBreaker breaker(String name) { + return breaker(name, 0, 0); + } + + /** + * Tool별 정책이 있으면 해당 threshold/open 시간을 우선 사용해 Circuit Breaker를 생성합니다. + */ + public CircuitBreaker breaker(String name, int failureThreshold, long openMillis) { + int effectiveThreshold = failureThreshold > 0 ? failureThreshold : properties.circuitBreakerFailureThreshold(); + long effectiveOpenMillis = openMillis > 0 ? openMillis : properties.circuitBreakerOpenMillis(); + return breakers.computeIfAbsent(name, key -> new CircuitBreaker( + key, + effectiveThreshold, + effectiveOpenMillis, + Clock.systemUTC())); + } + + /** + * Tool registry가 갱신될 때 Circuit Breaker 정책도 새 값으로 갱신합니다. + */ + public void refresh(String name, int failureThreshold, long openMillis) { + int effectiveThreshold = failureThreshold > 0 ? failureThreshold : properties.circuitBreakerFailureThreshold(); + long effectiveOpenMillis = openMillis > 0 ? openMillis : properties.circuitBreakerOpenMillis(); + breakers.put(name, new CircuitBreaker(name, effectiveThreshold, effectiveOpenMillis, Clock.systemUTC())); + } + + /** + * MCP Monitor 화면에 표시할 전체 Circuit Breaker 상태를 반환합니다. + */ + public List snapshots() { + return breakers.values().stream() + .map(CircuitBreaker::snapshot) + .sorted(Comparator.comparing(CircuitBreakerSnapshot::name)) + .toList(); + } + + /** + * 테스트 후 모든 Circuit Breaker 상태를 CLOSED로 초기화합니다. + */ + public void resetAll() { + breakers.values().forEach(CircuitBreaker::reset); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerSnapshot.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerSnapshot.java new file mode 100644 index 0000000..a271d92 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerSnapshot.java @@ -0,0 +1,18 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +/** + * MCP Monitor 화면에 내려주는 Circuit Breaker 상태 정보입니다. + */ +public record CircuitBreakerSnapshot( + String name, + String state, + int timeoutFailures, + int serverErrorFailures, + int timeoutOpenThreshold, + int serverErrorOpenThreshold, + String lastFailureType, + String openedReason, + String openedAt, + long remainingOpenMillis +) { +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerState.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerState.java new file mode 100644 index 0000000..425f685 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/CircuitBreakerState.java @@ -0,0 +1,12 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +/** + * Circuit Breaker의 현재 상태입니다. + * + * CLOSED는 정상 호출 가능, OPEN은 호출 차단, HALF_OPEN은 복구 확인 상태를 의미합니다. + */ +public enum CircuitBreakerState { + CLOSED, + OPEN, + HALF_OPEN +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/FailureType.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/FailureType.java new file mode 100644 index 0000000..ccf8a86 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/FailureType.java @@ -0,0 +1,20 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +/** + * Tool/EIMS/Tool 서버 호출 실패를 업무적으로 구분하기 위한 장애 유형입니다. + * + * Retry, Circuit Breaker, 감사 로그에서 같은 기준으로 오류를 판단합니다. + */ +public enum FailureType { + TIMEOUT, + CLIENT_ERROR, + SERVER_ERROR, + NETWORK_ERROR, + AUTHORIZATION_ERROR, + BUSINESS_ERROR, + HALLUCINATION_GUARDRAIL, + TOOL_DELETED, + CIRCUIT_OPEN, + TOO_LARGE_RESULT, + INTERNAL_ERROR +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/ToolExecutionException.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/ToolExecutionException.java new file mode 100644 index 0000000..b6e3eb8 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/resilience/ToolExecutionException.java @@ -0,0 +1,28 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.resilience; + +/** + * Tool 실행 중 발생한 오류를 FailureType과 함께 전달하는 공통 예외입니다. + * + * 상위 GatewayToolExecutor가 이 예외를 기준으로 감사 로그, Redis Trace, 재시도 여부를 판단합니다. + */ +public class ToolExecutionException extends RuntimeException { + private final FailureType failureType; + + public ToolExecutionException(String message) { + this(FailureType.BUSINESS_ERROR, message); + } + + public ToolExecutionException(FailureType failureType, String message) { + super(message); + this.failureType = failureType; + } + + public ToolExecutionException(FailureType failureType, String message, Throwable cause) { + super(message, cause); + this.failureType = failureType; + } + + public FailureType failureType() { + return failureType; + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContext.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContext.java new file mode 100644 index 0000000..b1288e8 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContext.java @@ -0,0 +1,21 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.security; + +import java.util.Set; + +/** + * Agent가 MCP 서버로 보낸 헤더 정보를 표준화한 요청 컨텍스트입니다. + * + * 이후 권한 검증, 감사 로그, Redis Trace, Tool 서버 호출 시 공통으로 사용됩니다. + */ +public record McpRequestContext( + String requestId, + String traceGroupId, + String clientAddress, + String agentId, + String userId, + Set roles, + Set allowedTools, + boolean claimsTrusted, + boolean writeApproved +) { +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContextResolver.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContextResolver.java new file mode 100644 index 0000000..6aabea0 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/security/McpRequestContextResolver.java @@ -0,0 +1,56 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Arrays; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Component +/** + * 현재 HTTP 요청에서 Agent/User/권한 관련 헤더를 읽어 McpRequestContext로 변환합니다. + * + * MCP Tool 메소드 자체는 HTTP 객체를 직접 받지 않으므로, 이 클래스가 요청 정보를 꺼내주는 역할을 합니다. + */ +public class McpRequestContextResolver { + /** + * 현재 요청의 헤더를 읽어 권한/추적용 컨텍스트를 생성합니다. + */ + public McpRequestContext current() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + String requestId = UUID.randomUUID().toString(); + return new McpRequestContext(requestId, requestId, "internal", "internal", "internal", Set.of(), Set.of(), true, true); + } + HttpServletRequest request = attributes.getRequest(); + String requestId = firstNonBlank(request.getHeader("X-Request-Id"), UUID.randomUUID().toString()); + return new McpRequestContext( + requestId, + firstNonBlank(request.getHeader("X-MCP-Trace-Group-Id"), requestId), + request.getRemoteAddr(), + firstNonBlank(request.getHeader("X-MCP-Agent-Id"), "anonymous"), + firstNonBlank(request.getHeader("X-MCP-User-Id"), "anonymous"), + csvHeader(request.getHeader("X-MCP-Roles")), + csvHeader(request.getHeader("X-MCP-Allowed-Tools")), + Boolean.parseBoolean(firstNonBlank(request.getHeader("X-MCP-Claims-Trusted"), "false")), + Boolean.parseBoolean(firstNonBlank(request.getHeader("X-MCP-Write-Approved"), "false"))); + } + + private String firstNonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private Set csvHeader(String value) { + if (value == null || value.isBlank()) { + return Set.of(); + } + return Arrays.stream(value.split(",")) + .map(String::trim) + .filter(item -> !item.isBlank()) + .collect(Collectors.toUnmodifiableSet()); + } +} 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 f6fefce..7e55ba9 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 @@ -18,6 +18,12 @@ 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 RestClient restClient = RestClient.create(); public Object execute(Map payload, String tenantId) { @@ -32,10 +38,24 @@ public class ExecuteService { var planObj = planner.createPlan(payload, tenantId); ToolMetadata plan = (ToolMetadata) planObj; + io.shinhanlife.axhub.biz.mcp.gateway.security.McpRequestContext context = contextResolver.current(); + + // --- Guardrail 검증 (Admin UI 및 AI 요청 모두 적용) --- + com.fasterxml.jackson.databind.node.ObjectNode argumentsNode = objectMapper.createObjectNode(); + if (params != null && params.containsKey("arguments")) { + argumentsNode = objectMapper.valueToTree(params.get("arguments")); + guardrailService.validate(plan, argumentsNode); + } + + auditLogService.toolStarted(context, toolName, 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()) { @@ -45,22 +65,43 @@ public class ExecuteService { 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 { - return executeWithUrl(payload, executeApiUrl); + 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 { - return executeWithUrl(payload, fallbackUrl); + 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 RuntimeException("Tool Pod 호출 실패 (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()); } } log.error(" [ExecuteService] Tool Pod 호출 실패: {}", e.getMessage()); - throw new RuntimeException("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); } } diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactory.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactory.java index 815f569..d25c939 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactory.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactory.java @@ -20,7 +20,6 @@ import java.util.UUID; */ @Component public class RegistryMcpToolSpecificationFactory { - private final ExecuteService executeService; private final ObjectMapper objectMapper; @@ -41,11 +40,11 @@ public class RegistryMcpToolSpecificationFactory { return McpStatelessServerFeatures.SyncToolSpecification.builder() .tool(tool) - .callHandler((context, request) -> execute(entry.getName(), request)) + .callHandler((context, request) -> execute(entry, request)) .build(); } - private McpSchema.CallToolResult execute(String toolName, McpSchema.CallToolRequest request) { + private McpSchema.CallToolResult execute(ToolMetadata entry, McpSchema.CallToolRequest request) { try { // Build legacy JSON-RPC payload format expected by ExecuteService Map payload = new HashMap<>(); @@ -54,7 +53,7 @@ public class RegistryMcpToolSpecificationFactory { payload.put("id", UUID.randomUUID().toString()); Map params = new HashMap<>(); - params.put("name", toolName); + params.put("name", entry.getName()); params.put("arguments", request.arguments()); payload.put("params", params);