feat: MCP Gateway Guardrail, AuditLog, CircuitBreaker 포팅 완료

This commit is contained in:
jade
2026-07-14 16:46:22 +09:00
parent 9f04151f72
commit 1eddc1eb36
16 changed files with 757 additions and 8 deletions

View File

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

View File

@@ -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<String> 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<String> 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<String> allowedToolSet() {
if (allowedTools == null) {
return Set.of();
}
return allowedTools.stream()
.filter(tool -> tool != null && !tool.isBlank())
.map(String::trim)
.collect(Collectors.toUnmodifiableSet());
}
}

View File

@@ -84,4 +84,15 @@ public class ToolMetadata {
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수
// --- Guardrail 호환성을 위한 메서드 추가 ---
public java.util.Set<String> allowedArguments() {
if (parametersSchema == null || !parametersSchema.containsKey("properties")) return java.util.Set.of();
return ((Map<String, Object>) parametersSchema.get("properties")).keySet();
}
public java.util.Set<String> requiredArguments() {
if (parametersSchema == null || !parametersSchema.containsKey("required")) return java.util.Set.of();
return new java.util.HashSet<>((java.util.List<String>) parametersSchema.get("required"));
}
}

View File

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

View File

@@ -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<String> 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<Map.Entry<String, JsonNode>> fields = input.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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");
}
}

View File

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

View File

@@ -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<CircuitBreakerSnapshot> snapshots() {
return circuitBreakers.snapshots();
}
/**
* 모든 Circuit Breaker를 CLOSED 상태로 초기화합니다.
*/
@PostMapping("/reset")
public Map<String, Object> reset() {
circuitBreakers.resetAll();
return Map.of("reset", true, "items", circuitBreakers.snapshots());
}
}

View File

@@ -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<String, CircuitBreaker> 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<CircuitBreakerSnapshot> 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> roles,
Set<String> allowedTools,
boolean claimsTrusted,
boolean writeApproved
) {
}

View File

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

View File

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

View File

@@ -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<String, Object> payload = new HashMap<>();
@@ -54,7 +53,7 @@ public class RegistryMcpToolSpecificationFactory {
payload.put("id", UUID.randomUUID().toString());
Map<String, Object> params = new HashMap<>();
params.put("name", toolName);
params.put("name", entry.getName());
params.put("arguments", request.arguments());
payload.put("params", params);