forked from kimhyungsik/ax_hub_mcp_tool
feat: MCP Gateway Guardrail, AuditLog, CircuitBreaker 포팅 완료
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,4 +84,15 @@ public class ToolMetadata {
|
|||||||
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
|
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
|
||||||
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
|
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
|
||||||
private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ public class ExecuteService {
|
|||||||
|
|
||||||
private final ToolPlanner planner;
|
private final ToolPlanner planner;
|
||||||
private final KillSwitchService killSwitchService;
|
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();
|
private final RestClient restClient = RestClient.create();
|
||||||
|
|
||||||
public Object execute(Map<String, Object> payload, String tenantId) {
|
public Object execute(Map<String, Object> payload, String tenantId) {
|
||||||
@@ -32,10 +38,24 @@ public class ExecuteService {
|
|||||||
var planObj = planner.createPlan(payload, tenantId);
|
var planObj = planner.createPlan(payload, tenantId);
|
||||||
ToolMetadata plan = (ToolMetadata) planObj;
|
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) {
|
if (plan.getIntegrationType() != null) {
|
||||||
killSwitchService.checkRoute(plan.getIntegrationType());
|
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
|
// Dynamic Routing: Find the target Pod from the Redis registry
|
||||||
String targetUrl = "http://localhost:8084"; // Fallback (tool-other)
|
String targetUrl = "http://localhost:8084"; // Fallback (tool-other)
|
||||||
if (plan.getPodUrl() != null && !plan.getPodUrl().isEmpty()) {
|
if (plan.getPodUrl() != null && !plan.getPodUrl().isEmpty()) {
|
||||||
@@ -45,22 +65,43 @@ public class ExecuteService {
|
|||||||
log.info(" [ExecuteService] 라우팅 목적지: {}", targetUrl);
|
log.info(" [ExecuteService] 라우팅 목적지: {}", targetUrl);
|
||||||
String executeApiUrl = targetUrl + "/mcp/api/v1/tools/call";
|
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
|
// Forward the request to the target tool pod using RestClient with fallback
|
||||||
try {
|
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) {
|
} 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-")) {
|
if (executeApiUrl.contains("http://tool-")) {
|
||||||
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
|
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
|
||||||
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
|
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
|
||||||
try {
|
try {
|
||||||
return executeWithUrl(payload, fallbackUrl);
|
Object result = executeWithUrl(payload, fallbackUrl);
|
||||||
|
circuitBreaker.recordSuccess();
|
||||||
|
success = true;
|
||||||
|
errorCode = null;
|
||||||
|
return result;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
circuitBreaker.recordFailure(io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType.SERVER_ERROR);
|
||||||
log.error(" [ExecuteService] localhost 재시도 실패: {}", ex.getMessage());
|
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());
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class RegistryMcpToolSpecificationFactory {
|
public class RegistryMcpToolSpecificationFactory {
|
||||||
|
|
||||||
private final ExecuteService executeService;
|
private final ExecuteService executeService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -41,11 +40,11 @@ public class RegistryMcpToolSpecificationFactory {
|
|||||||
|
|
||||||
return McpStatelessServerFeatures.SyncToolSpecification.builder()
|
return McpStatelessServerFeatures.SyncToolSpecification.builder()
|
||||||
.tool(tool)
|
.tool(tool)
|
||||||
.callHandler((context, request) -> execute(entry.getName(), request))
|
.callHandler((context, request) -> execute(entry, request))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private McpSchema.CallToolResult execute(String toolName, McpSchema.CallToolRequest request) {
|
private McpSchema.CallToolResult execute(ToolMetadata entry, McpSchema.CallToolRequest request) {
|
||||||
try {
|
try {
|
||||||
// Build legacy JSON-RPC payload format expected by ExecuteService
|
// Build legacy JSON-RPC payload format expected by ExecuteService
|
||||||
Map<String, Object> payload = new HashMap<>();
|
Map<String, Object> payload = new HashMap<>();
|
||||||
@@ -54,7 +53,7 @@ public class RegistryMcpToolSpecificationFactory {
|
|||||||
payload.put("id", UUID.randomUUID().toString());
|
payload.put("id", UUID.randomUUID().toString());
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("name", toolName);
|
params.put("name", entry.getName());
|
||||||
params.put("arguments", request.arguments());
|
params.put("arguments", request.arguments());
|
||||||
payload.put("params", params);
|
payload.put("params", params);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user