From 6d98931ce1414412d30eb89b3fe151bf21708e92 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 28 Jul 2026 10:31:20 +0900 Subject: [PATCH] refs #739: normalize MCP query plan envelopes --- .../service/SelectAiService.java | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 5747c95..e9adf87 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -185,14 +186,16 @@ public class SelectAiService { } private QueryPlanContext requiredQueryPlan(JsonNode plan) { - String targetType = plan.path("targetType").asText("").trim().toUpperCase(Locale.ROOT); + JsonNode normalizedPlan = unwrapMcpToolResult(plan); + String targetType = normalizedPlan.path("targetType") + .asText("").trim().toUpperCase(Locale.ROOT); if (!Set.of("NONE", "SINGLE", "MULTI", "ALL").contains(targetType) - || !plan.path("targets").isArray()) { + || !normalizedPlan.path("targets").isArray()) { throw new AppException( "queryPlan은 targetType(NONE/SINGLE/MULTI/ALL)과 targets 배열이 필요합니다."); } Set allowedPrefixes = new LinkedHashSet<>(); - for (JsonNode target : plan.path("targets")) { + for (JsonNode target : normalizedPlan.path("targets")) { String prefix = target.path("gamePrefix").asText("").trim(); if (!prefix.isEmpty()) { allowedPrefixes.add(prefix.toUpperCase(Locale.ROOT)); @@ -200,13 +203,40 @@ public class SelectAiService { } return new QueryPlanContext( targetType, - plan.path("status").asText(""), - plan.path("targets").size(), + normalizedPlan.path("status").asText(""), + normalizedPlan.path("targets").size(), Set.copyOf(allowedPrefixes), - plan.deepCopy() + normalizedPlan.deepCopy() ); } + /** + * Accept the direct game-query-plan contract as well as the standard MCP + * tools/call envelope the ReAct model can copy from a preceding observation. + * This is transport normalization only; the target contract itself remains + * the authoritative data-driven policy. + */ + private JsonNode unwrapMcpToolResult(JsonNode candidate) { + JsonNode current = candidate; + for (int depth = 0; depth < 3 && current != null; depth++) { + if (current.isTextual()) { + try { + current = objectMapper.readTree(current.asText()); + continue; + } catch (JsonProcessingException ignored) { + return current; + } + } + JsonNode response = current.path("response"); + if (response.isObject() || response.isTextual()) { + current = response; + continue; + } + break; + } + return current == null ? objectMapper.createObjectNode() : current; + } + private GameContext resolveGameContext(String bearerToken, String question) { try { List candidates =