feat: 이식 완료 - MCP Gateway 응답 구조화, 가드레일, 통신 계층 추상화 적용

This commit is contained in:
jade
2026-07-14 21:15:26 +09:00
parent c3d8818dfd
commit abd4798cbc
6 changed files with 336 additions and 25 deletions

View File

@@ -0,0 +1,64 @@
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 org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Tool 서버 응답을 Agent에게 전달하기 전에 환각 방어 규칙을 적용하는 서비스입니다.
*/
@Service
public class ToolResponseGuardrailService {
private final ObjectMapper json;
public ToolResponseGuardrailService(ObjectMapper json) {
this.json = json;
}
/**
* Tool 응답 data를 검증한 뒤 표준 MCP 응답 문자열로 감쌉니다.
*/
public String validateAndWrap(ToolMetadata metadata, String requestId, JsonNode data) {
validateData(metadata.getName(), data);
ObjectNode response = json.createObjectNode();
response.put("success", true);
response.put("toolName", metadata.getName());
response.put("requestId", requestId);
response.put("source", "registered-tool-server");
response.set("data", data);
response.set("answerPolicy", answerPolicy());
try {
return json.writerWithDefaultPrettyPrinter().writeValueAsString(response);
} catch (Exception error) {
throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "검증된 Tool 응답 직렬화에 실패했습니다.", error);
}
}
/**
* Tool 응답 data를 Agent에게 전달하기 전에 응답 규격으로 검증합니다.
*/
public void validateData(String toolName, JsonNode data) {
if (data == null || data.isMissingNode() || data.isNull()) {
throw new ToolExecutionException(FailureType.HALLUCINATION_GUARDRAIL, "Tool 응답 data가 비어 있습니다. tool=" + toolName);
}
// 향후 ToolMetadata에 responseAllowedFields 등이 추가되면 스키마 검증 로직 추가
}
/**
* Agent가 Tool 결과 밖의 내용을 추측하지 않도록 응답 정책을 함께 내려줍니다.
*/
private ObjectNode answerPolicy() {
ObjectNode policy = json.createObjectNode();
policy.put("allowOnlyToolData", true);
policy.put("noGuessing", true);
policy.put("onMissingData", "answer_unknown_or_request_more_information");
policy.put("instruction", "Tool이 반환한 data 필드만 사용하세요. data에 없는 값은 추측해서 만들지 마세요.");
return policy;
}
}

View File

@@ -29,6 +29,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import io.shinhanlife.axhub.biz.mcp.gateway.tool.large.LargeToolResponseService; import io.shinhanlife.axhub.biz.mcp.gateway.tool.large.LargeToolResponseService;
import io.shinhanlife.axhub.biz.mcp.gateway.tool.large.PaginationRequestValidator; import io.shinhanlife.axhub.biz.mcp.gateway.tool.large.PaginationRequestValidator;
import io.shinhanlife.axhub.biz.mcp.gateway.tool.result.ToolExecutionResultFormatter;
import io.shinhanlife.axhub.biz.mcp.gateway.tool.result.ToolExecutionResult;
import io.shinhanlife.axhub.biz.mcp.gateway.guardrail.ToolResponseGuardrailService;
import io.shinhanlife.axhub.biz.mcp.gateway.transport.ToolInvoker;
@Slf4j @Slf4j
@Service @Service
@@ -47,7 +51,9 @@ public class ExecuteService {
private final McpGatewayProperties properties; private final McpGatewayProperties properties;
private final LargeToolResponseService largeResponses; private final LargeToolResponseService largeResponses;
private final PaginationRequestValidator paginationValidator; private final PaginationRequestValidator paginationValidator;
private final RestClient restClient = RestClient.create(); private final ToolExecutionResultFormatter resultFormatter;
private final ToolResponseGuardrailService responseGuardrail;
private final ToolInvoker toolInvoker;
private final ExecutorService executor; private final ExecutorService executor;
public ExecuteService(ToolPlanner planner, public ExecuteService(ToolPlanner planner,
@@ -62,7 +68,10 @@ public class ExecuteService {
RedisToolTraceService redisTrace, RedisToolTraceService redisTrace,
McpGatewayProperties properties, McpGatewayProperties properties,
LargeToolResponseService largeResponses, LargeToolResponseService largeResponses,
PaginationRequestValidator paginationValidator) { PaginationRequestValidator paginationValidator,
ToolExecutionResultFormatter resultFormatter,
ToolResponseGuardrailService responseGuardrail,
ToolInvoker toolInvoker) {
this.planner = planner; this.planner = planner;
this.killSwitchService = killSwitchService; this.killSwitchService = killSwitchService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@@ -76,6 +85,9 @@ public class ExecuteService {
this.properties = properties; this.properties = properties;
this.largeResponses = largeResponses; this.largeResponses = largeResponses;
this.paginationValidator = paginationValidator; this.paginationValidator = paginationValidator;
this.resultFormatter = resultFormatter;
this.responseGuardrail = responseGuardrail;
this.toolInvoker = toolInvoker;
this.executor = new ThreadPoolExecutor( this.executor = new ThreadPoolExecutor(
properties.toolExecutorCorePoolSize(), properties.toolExecutorCorePoolSize(),
@@ -123,7 +135,12 @@ public class ExecuteService {
Object result = executeWithResilience(context, metadata, argumentsNode, payload); Object result = executeWithResilience(context, metadata, argumentsNode, payload);
if (result instanceof com.fasterxml.jackson.databind.JsonNode) { if (result instanceof com.fasterxml.jackson.databind.JsonNode) {
result = objectMapper.convertValue(result, Object.class); // Apply Output Guardrail
String wrappedResponse = responseGuardrail.validateAndWrap(metadata, context.requestId(), (com.fasterxml.jackson.databind.JsonNode) result);
// Format the result
ToolExecutionResult formattedResult = resultFormatter.fromRawResponse(metadata.getName(), wrappedResponse);
result = formattedResult;
} }
long elapsedMillis = elapsedMillis(startedAt); long elapsedMillis = elapsedMillis(startedAt);
@@ -210,15 +227,13 @@ public class ExecuteService {
JsonNode data = null; JsonNode data = null;
try { try {
Object httpResult = executeWithUrl(pagePayload, executeApiUrl); data = toolInvoker.invoke(pagePayload, executeApiUrl);
data = extractData(objectMapper.valueToTree(httpResult));
} catch (Exception e) { } catch (Exception e) {
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 {
Object httpResult = executeWithUrl(pagePayload, fallbackUrl); data = toolInvoker.invoke(pagePayload, fallbackUrl);
data = extractData(objectMapper.valueToTree(httpResult));
} catch (Exception ex) { } catch (Exception ex) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
} }
@@ -268,24 +283,6 @@ public class ExecuteService {
} }
} }
private Object executeWithUrl(Map<String, Object> payload, String url) {
return restClient.post()
.uri(url)
.contentType(MediaType.APPLICATION_JSON)
// TODO: Use actual tenant's key
.header("X-Trace-Id", UUID.randomUUID().toString())
.body(payload)
.retrieve()
.body(Object.class);
}
private JsonNode extractData(JsonNode root) {
if (!root.path("success").asBoolean(true)) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText());
}
return root.has("data") ? root.get("data") : root;
}
private RetryPolicy retryPolicy(ToolMetadata metadata, ObjectNode arguments) { private RetryPolicy retryPolicy(ToolMetadata metadata, ObjectNode arguments) {
if (!retryAllowedByOperation(metadata, arguments)) { if (!retryAllowedByOperation(metadata, arguments)) {
return RetryPolicy.disabled(); return RetryPolicy.disabled();

View File

@@ -0,0 +1,35 @@
package io.shinhanlife.axhub.biz.mcp.gateway.tool.result;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import java.util.Map;
/**
* MCP Tool 실행 결과를 text 요약과 structuredContent로 분리해서 표현합니다.
*/
public record ToolExecutionResult(
ResultType resultType,
List<ContentItem> content,
JsonNode structuredContent,
List<ResourceLink> resourceLinks,
Map<String, Object> metadata,
boolean isError,
boolean truncated,
long originalSizeBytes
) {
public record ContentItem(String type, String text) {
}
public record ResourceLink(String uri, String name, String mimeType, long sizeBytes) {
}
public enum ResultType {
JSON_OBJECT,
JSON_ARRAY,
PLAIN_TEXT,
BINARY_FILE,
EMPTY,
ERROR
}
}

View File

@@ -0,0 +1,153 @@
package io.shinhanlife.axhub.biz.mcp.gateway.tool.result;
import io.shinhanlife.axhub.biz.mcp.gateway.guardrail.SensitiveDataMasker;
import org.springframework.stereotype.Service;
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.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Tool 서버 원시 응답을 MCP의 content 요약과 structuredContent로 분리합니다.
*/
@Service
public class ToolExecutionResultFormatter {
private static final int TEXT_PREVIEW_LIMIT = 2_000;
private final ObjectMapper json;
private final SensitiveDataMasker masker;
public ToolExecutionResultFormatter(ObjectMapper json, SensitiveDataMasker masker) {
this.json = json;
this.masker = masker;
}
/**
* JSON 문자열을 content.text에 그대로 넣지 않고 structuredContent로 분리합니다.
*/
public ToolExecutionResult fromRawResponse(String toolName, String rawResponse) {
long sizeBytes = rawResponse == null ? 0 : rawResponse.getBytes(StandardCharsets.UTF_8).length;
if (rawResponse == null || rawResponse.isBlank()) {
return result(ToolExecutionResult.ResultType.EMPTY, "Tool 응답이 비어 있습니다.",
json.createObjectNode(), Map.of("toolName", toolName), false, false, sizeBytes);
}
try {
JsonNode parsed = json.readTree(rawResponse);
return fromJson(toolName, parsed, sizeBytes);
} catch (Exception parseError) {
ObjectNode metadata = json.createObjectNode();
metadata.put("toolName", toolName);
metadata.put("parseError", "JSON_PARSE_FAILED");
metadata.put("preview", preview(rawResponse));
return new ToolExecutionResult(
ToolExecutionResult.ResultType.PLAIN_TEXT,
List.of(new ToolExecutionResult.ContentItem("text", "Tool 응답이 JSON 형식이 아니어서 제한된 preview만 제공합니다.")),
null,
List.of(),
Map.of("toolName", toolName, "parseError", "JSON_PARSE_FAILED", "preview", preview(rawResponse)),
false,
rawResponse.length() > TEXT_PREVIEW_LIMIT,
sizeBytes);
}
}
public ToolExecutionResult fromJson(String toolName, JsonNode parsed, long sizeBytes) {
JsonNode masked = masker.mask(parsed);
if (masked.isObject()) {
ObjectNode object = (ObjectNode) masked;
if (object.path("isError").asBoolean(false) || object.has("error") || object.has("failureType")) {
return result(ToolExecutionResult.ResultType.ERROR, errorSummary(toolName, object), object,
metadata(toolName, sizeBytes), true, object.path("truncated").asBoolean(false), sizeBytes);
}
JsonNode structured = object.has("structuredContent") ? object.path("structuredContent") : structuredFromObject(object);
return result(ToolExecutionResult.ResultType.JSON_OBJECT, summary(toolName, object, structured), structured,
metadata(toolName, sizeBytes), false, object.path("truncated").asBoolean(false), sizeBytes);
}
if (masked.isArray()) {
ObjectNode structured = json.createObjectNode();
structured.set("items", masked);
structured.put("count", count(masked));
return result(ToolExecutionResult.ResultType.JSON_ARRAY,
toolName + " 결과 " + count(masked) + "건을 조회했습니다.",
structured,
metadata(toolName, sizeBytes),
false,
false,
sizeBytes);
}
if (masked.isTextual()) {
String text = masked.asText("");
return result(ToolExecutionResult.ResultType.PLAIN_TEXT, preview(text), null,
metadata(toolName, sizeBytes), false, text.length() > TEXT_PREVIEW_LIMIT, sizeBytes);
}
ObjectNode structured = json.createObjectNode();
structured.set("value", masked);
return result(ToolExecutionResult.ResultType.JSON_OBJECT, toolName + " 결과를 조회했습니다.",
structured, metadata(toolName, sizeBytes), false, false, sizeBytes);
}
private JsonNode structuredFromObject(ObjectNode object) {
if (object.has("data")) {
return object.path("data");
}
return object;
}
private String summary(String toolName, ObjectNode object, JsonNode structured) {
String message = object.path("message").asText("");
if (!message.isBlank()) {
return message;
}
if (structured != null && structured.isObject()) {
long totalCount = structured.path("totalCount").asLong(-1);
if (totalCount >= 0) {
return toolName + " 대량 결과 preview 조회가 완료되었습니다. totalCount=" + totalCount;
}
}
if (structured != null && structured.isArray()) {
return toolName + " 결과 " + count(structured) + "건을 조회했습니다.";
}
return toolName + " 조회가 완료되었습니다.";
}
private String errorSummary(String toolName, ObjectNode object) {
String message = object.path("message").asText(object.path("error").asText(""));
return message.isBlank() ? toolName + " 호출 중 오류가 발생했습니다." : message;
}
private ToolExecutionResult result(ToolExecutionResult.ResultType type, String text, JsonNode structuredContent,
Map<String, Object> metadata, boolean error, boolean truncated, long sizeBytes) {
return new ToolExecutionResult(
type,
List.of(new ToolExecutionResult.ContentItem("text", text)),
structuredContent,
List.of(),
metadata,
error,
truncated,
sizeBytes);
}
private Map<String, Object> metadata(String toolName, long sizeBytes) {
return Map.of("toolName", toolName, "originalSizeBytes", sizeBytes);
}
private int count(JsonNode node) {
int count = 0;
for (JsonNode ignored : node) {
count++;
}
return count;
}
private String preview(String value) {
if (value == null) {
return "";
}
return value.length() <= TEXT_PREVIEW_LIMIT ? value : value.substring(0, TEXT_PREVIEW_LIMIT);
}
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.axhub.biz.mcp.gateway.transport;
import io.shinhanlife.axhub.biz.mcp.gateway.resilience.FailureType;
import io.shinhanlife.axhub.biz.mcp.gateway.resilience.ToolExecutionException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Component
public class HttpToolInvoker implements ToolInvoker {
private final RestClient restClient;
private final ObjectMapper objectMapper;
public HttpToolInvoker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.restClient = RestClient.create();
}
@Override
public JsonNode invoke(Map<String, Object> payload, String targetUrl) {
try {
Object httpResult = restClient.post()
.uri(targetUrl)
.contentType(MediaType.APPLICATION_JSON)
// TODO: Use actual tenant's key
.header("X-Trace-Id", UUID.randomUUID().toString())
.body(payload)
.retrieve()
.body(Object.class);
return extractData(objectMapper.valueToTree(httpResult));
} catch (Exception e) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e);
}
}
private JsonNode extractData(JsonNode root) {
if (!root.path("success").asBoolean(true)) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText());
}
return root.has("data") ? root.get("data") : root;
}
}

View File

@@ -0,0 +1,11 @@
package io.shinhanlife.axhub.biz.mcp.gateway.transport;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Map;
/**
* Tool 서버 호출 transport의 최소 공통 인터페이스입니다.
*/
public interface ToolInvoker {
JsonNode invoke(Map<String, Object> payload, String targetUrl);
}