feat: 이식 완료 - MCP Gateway 응답 구조화, 가드레일, 통신 계층 추상화 적용
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.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
|
||||
@Service
|
||||
@@ -47,7 +51,9 @@ public class ExecuteService {
|
||||
private final McpGatewayProperties properties;
|
||||
private final LargeToolResponseService largeResponses;
|
||||
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;
|
||||
|
||||
public ExecuteService(ToolPlanner planner,
|
||||
@@ -62,7 +68,10 @@ public class ExecuteService {
|
||||
RedisToolTraceService redisTrace,
|
||||
McpGatewayProperties properties,
|
||||
LargeToolResponseService largeResponses,
|
||||
PaginationRequestValidator paginationValidator) {
|
||||
PaginationRequestValidator paginationValidator,
|
||||
ToolExecutionResultFormatter resultFormatter,
|
||||
ToolResponseGuardrailService responseGuardrail,
|
||||
ToolInvoker toolInvoker) {
|
||||
this.planner = planner;
|
||||
this.killSwitchService = killSwitchService;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -76,6 +85,9 @@ public class ExecuteService {
|
||||
this.properties = properties;
|
||||
this.largeResponses = largeResponses;
|
||||
this.paginationValidator = paginationValidator;
|
||||
this.resultFormatter = resultFormatter;
|
||||
this.responseGuardrail = responseGuardrail;
|
||||
this.toolInvoker = toolInvoker;
|
||||
|
||||
this.executor = new ThreadPoolExecutor(
|
||||
properties.toolExecutorCorePoolSize(),
|
||||
@@ -123,7 +135,12 @@ public class ExecuteService {
|
||||
Object result = executeWithResilience(context, metadata, argumentsNode, payload);
|
||||
|
||||
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);
|
||||
@@ -210,15 +227,13 @@ public class ExecuteService {
|
||||
|
||||
JsonNode data = null;
|
||||
try {
|
||||
Object httpResult = executeWithUrl(pagePayload, executeApiUrl);
|
||||
data = extractData(objectMapper.valueToTree(httpResult));
|
||||
data = toolInvoker.invoke(pagePayload, executeApiUrl);
|
||||
} catch (Exception e) {
|
||||
if (executeApiUrl.contains("http://tool-")) {
|
||||
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
|
||||
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
|
||||
try {
|
||||
Object httpResult = executeWithUrl(pagePayload, fallbackUrl);
|
||||
data = extractData(objectMapper.valueToTree(httpResult));
|
||||
data = toolInvoker.invoke(pagePayload, fallbackUrl);
|
||||
} catch (Exception ex) {
|
||||
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
|
||||
}
|
||||
@@ -267,24 +282,6 @@ public class ExecuteService {
|
||||
throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool 실행이 중단되었습니다: " + metadata.getName(), error);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!retryAllowedByOperation(metadata, arguments)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user