diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java new file mode 100644 index 0000000..e3481f0 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/guardrail/ToolResponseGuardrailService.java @@ -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; + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java index d5079c4..b1e7828 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/service/ExecuteService.java @@ -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 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)) { diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResult.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResult.java new file mode 100644 index 0000000..7421b37 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResult.java @@ -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 content, + JsonNode structuredContent, + List resourceLinks, + Map 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 + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResultFormatter.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResultFormatter.java new file mode 100644 index 0000000..819bd09 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/tool/result/ToolExecutionResultFormatter.java @@ -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 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 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); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/HttpToolInvoker.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/HttpToolInvoker.java new file mode 100644 index 0000000..9087c44 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/HttpToolInvoker.java @@ -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 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; + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/ToolInvoker.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/ToolInvoker.java new file mode 100644 index 0000000..5aabf2c --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/transport/ToolInvoker.java @@ -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 payload, String targetUrl); +}