505 lines
21 KiB
Java
505 lines
21 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
|
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
|
|
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.List;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
/** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */
|
|
@Service
|
|
public class McpSseService {
|
|
|
|
private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)";
|
|
private static final String GAME_CATALOG_TOOL = "oracle.select_ai.game_catalog_resolve";
|
|
private static final String GAME_QUERY_PLAN_TOOL = "oracle.select_ai.game_query_plan";
|
|
|
|
private final SelectAiService selectAiService;
|
|
private final ObjectMapper objectMapper;
|
|
private final BackofficeProperties properties;
|
|
private final McpProperties mcpProperties;
|
|
private final QaVectorService qaVectorService;
|
|
private final GameScopeService gameScopeService;
|
|
private final GameCatalogVectorService gameCatalogVectorService;
|
|
|
|
public McpSseService(
|
|
SelectAiService selectAiService,
|
|
ObjectMapper objectMapper,
|
|
BackofficeProperties properties,
|
|
McpProperties mcpProperties,
|
|
QaVectorService qaVectorService,
|
|
GameScopeService gameScopeService,
|
|
GameCatalogVectorService gameCatalogVectorService
|
|
) {
|
|
this.selectAiService = selectAiService;
|
|
this.objectMapper = objectMapper;
|
|
this.properties = properties;
|
|
this.mcpProperties = mcpProperties;
|
|
this.qaVectorService = qaVectorService;
|
|
this.gameScopeService = gameScopeService;
|
|
this.gameCatalogVectorService = gameCatalogVectorService;
|
|
}
|
|
|
|
public ObjectNode handle(String contextPath, JsonNode request) {
|
|
return handle(contextPath, request, "");
|
|
}
|
|
|
|
/**
|
|
* The HTTP bearer token is the business-user subject token; no separate MCP token is used.
|
|
*/
|
|
public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("jsonrpc", "2.0");
|
|
if (request != null && request.has("id")) {
|
|
response.set("id", request.get("id"));
|
|
}
|
|
|
|
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
|
JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params");
|
|
try {
|
|
response.set("result", switch (method) {
|
|
case "initialize" -> initializeResult(contextPath);
|
|
case "notifications/initialized" -> objectMapper.createObjectNode();
|
|
case "tools/list" -> toolsListResult();
|
|
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
|
|
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
|
});
|
|
} catch (Exception e) {
|
|
response.remove("result");
|
|
ObjectNode error = objectMapper.createObjectNode();
|
|
error.put("code", -32000);
|
|
error.put("message", e.getMessage());
|
|
response.set("error", error);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/** Tools registered by this MCP server. */
|
|
public List<McpToolView> registeredTools() {
|
|
return List.of(
|
|
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(),
|
|
qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView());
|
|
}
|
|
|
|
private ObjectNode initializeResult(String contextPath) {
|
|
ObjectNode result = objectMapper.createObjectNode();
|
|
result.put("protocolVersion", "2024-11-05");
|
|
ObjectNode serverInfo = objectMapper.createObjectNode();
|
|
serverInfo.put("name", "vpd-ords-backoffice-" + contextPath);
|
|
serverInfo.put("version", "0.1.0");
|
|
result.set("serverInfo", serverInfo);
|
|
ObjectNode capabilities = objectMapper.createObjectNode();
|
|
capabilities.set("tools", objectMapper.createObjectNode());
|
|
result.set("capabilities", capabilities);
|
|
return result;
|
|
}
|
|
|
|
private ObjectNode toolsListResult() {
|
|
ObjectNode result = objectMapper.createObjectNode();
|
|
ArrayNode tools = objectMapper.createArrayNode();
|
|
tools.add(toolDefinition(selectAiQueryView()));
|
|
tools.add(toolDefinition(selectAiShowpromptView()));
|
|
tools.add(toolDefinition(qaVectorSearchView()));
|
|
tools.add(toolDefinition(qaVectorStoreView()));
|
|
tools.add(toolDefinition(fewShotNl2SqlView()));
|
|
tools.add(toolDefinition(gameScopeView()));
|
|
tools.add(toolDefinition(gameCatalogView()));
|
|
tools.add(toolDefinition(gameQueryPlanView()));
|
|
result.set("tools", tools);
|
|
return result;
|
|
}
|
|
|
|
private ObjectNode toolDefinition(McpToolView toolView) {
|
|
ObjectNode item = objectMapper.createObjectNode();
|
|
item.put("name", toolView.name());
|
|
item.put("description", toolView.description());
|
|
|
|
ObjectNode schema = objectMapper.createObjectNode();
|
|
schema.put("type", "object");
|
|
ObjectNode properties = objectMapper.createObjectNode();
|
|
|
|
ArrayNode required = objectMapper.createArrayNode();
|
|
if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())
|
|
|| GAME_CATALOG_TOOL.equals(toolView.name()) || GAME_QUERY_PLAN_TOOL.equals(toolView.name())) {
|
|
addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000);
|
|
ObjectNode topK = properties.putObject("topK");
|
|
topK.put("type", "integer");
|
|
topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다.");
|
|
topK.put("minimum", 1);
|
|
topK.put("maximum", 20);
|
|
topK.put("default", 3);
|
|
required.add("question");
|
|
} else if (qaVectorStoreToolName().equals(toolView.name())) {
|
|
addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000);
|
|
addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000);
|
|
addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000);
|
|
required.add("question");
|
|
required.add("answerSql");
|
|
} else {
|
|
addStringProperty(properties, "prompt", promptDescription(), 4000);
|
|
if (fewShotNl2SqlToolName().equals(toolView.name())) {
|
|
addStringProperty(properties, "scopeGameKey",
|
|
"선택 사항입니다. game_scope_resolve가 반환한 SUPPORTED gameKey만 전달하세요.", 128);
|
|
}
|
|
required.add("prompt");
|
|
}
|
|
schema.set("properties", properties);
|
|
schema.set("required", required);
|
|
schema.put("additionalProperties", false);
|
|
item.set("inputSchema", schema);
|
|
return item;
|
|
}
|
|
|
|
private void addStringProperty(ObjectNode properties, String name, String description, int maxLength) {
|
|
ObjectNode property = properties.putObject(name);
|
|
property.put("type", "string");
|
|
property.put("description", description);
|
|
property.put("maxLength", maxLength);
|
|
}
|
|
|
|
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
|
String calledToolName = params.path("name").asText("");
|
|
boolean queryTool = toolName().equals(calledToolName);
|
|
boolean showpromptTool = showpromptToolName().equals(calledToolName);
|
|
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
|
|
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
|
|
boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName);
|
|
boolean gameScopeTool = gameScopeToolName().equals(calledToolName);
|
|
boolean gameCatalogTool = GAME_CATALOG_TOOL.equals(calledToolName);
|
|
boolean gameQueryPlanTool = GAME_QUERY_PLAN_TOOL.equals(calledToolName);
|
|
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool
|
|
&& !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool) {
|
|
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
|
|
}
|
|
|
|
JsonNode arguments = params.path("arguments");
|
|
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
|
|
if (token.isBlank()) {
|
|
return tokenAccessDeniedResult();
|
|
}
|
|
JsonNode response;
|
|
try {
|
|
if (qaVectorSearchTool) {
|
|
response = qaVectorSearchResponse(
|
|
qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3)));
|
|
} else if (qaVectorStoreTool) {
|
|
response = qaVectorStoreResponse(qaVectorService.store(
|
|
token,
|
|
arguments.path("question").asText(""),
|
|
arguments.path("answerSql").asText(""),
|
|
arguments.path("answer").isMissingNode() ? null : arguments.path("answer").asText(null)
|
|
));
|
|
} else if (gameScopeTool) {
|
|
response = gameScopeResponse(gameScopeService.resolve(
|
|
token, arguments.path("question").asText("")));
|
|
} else if (gameCatalogTool) {
|
|
response = gameCatalogVectorResponse(gameCatalogVectorService.resolve(
|
|
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
|
|
} else if (gameQueryPlanTool) {
|
|
response = gameQueryPlanResponse(gameCatalogVectorService.resolve(
|
|
token, arguments.path("question").asText(""), arguments.path("topK").asInt(5)));
|
|
} else {
|
|
String prompt = arguments.path("prompt").asText("");
|
|
response = queryTool
|
|
? selectAiService.generateAndExecute(token, prompt)
|
|
: fewShotNl2SqlTool
|
|
? selectAiService.generateAndExecute(token, prompt,
|
|
arguments.path("scopeGameKey").asText(""))
|
|
: selectAiService.generatePrompt(token, prompt);
|
|
}
|
|
} catch (VpdTokenAccessDeniedException ignored) {
|
|
return tokenAccessDeniedResult();
|
|
}
|
|
|
|
ObjectNode payload = objectMapper.createObjectNode();
|
|
payload.put("toolName", calledToolName);
|
|
payload.put("profile", selectAiProfile());
|
|
payload.put("ordsPath", SELECT_AI_TOOL_PATH);
|
|
payload.set("response", response);
|
|
|
|
ObjectNode result = objectMapper.createObjectNode();
|
|
ArrayNode content = objectMapper.createArrayNode();
|
|
ObjectNode text = objectMapper.createObjectNode();
|
|
text.put("type", "text");
|
|
text.put("text", pretty(payload));
|
|
content.add(text);
|
|
result.set("content", content);
|
|
result.put("isError", false);
|
|
return result;
|
|
}
|
|
|
|
private ObjectNode qaVectorSearchResponse(QaVectorService.VectorSearchResult result) {
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("status", "QA_VECTOR_SEARCH");
|
|
response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다.");
|
|
response.put("question", result.question());
|
|
response.put("topK", result.topK());
|
|
ArrayNode examples = response.putArray("examples");
|
|
for (QaVectorService.VectorExample example : result.examples()) {
|
|
ObjectNode item = examples.addObject();
|
|
item.put("exampleId", example.exampleId());
|
|
item.put("question", example.question());
|
|
item.put("answerSql", example.answerSql());
|
|
if (example.answer() != null) {
|
|
item.put("answer", example.answer());
|
|
}
|
|
item.put("embeddingModel", example.embeddingModel());
|
|
item.put("cosineDistance", example.cosineDistance());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) {
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("scopeType", resolution.scopeType());
|
|
response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "CANDIDATES");
|
|
ArrayNode items = response.putArray("matchedGames");
|
|
for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) {
|
|
ObjectNode item = items.addObject();
|
|
item.put("gameKey", c.gameKey()); item.put("gameId", c.gameId());
|
|
item.put("gamePrefix", c.gamePrefix()); item.put("gameName", c.gameName());
|
|
item.put("aliases", c.aliases()); item.put("cosineDistance", c.similarity());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) {
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("status", "QA_VECTOR_STORED");
|
|
response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다.");
|
|
response.put("exampleId", stored.exampleId());
|
|
response.put("question", stored.question());
|
|
response.put("embeddingModel", stored.embeddingModel());
|
|
return response;
|
|
}
|
|
|
|
/** Converts DB-derived scope facts to a tool response; this tool never executes query SQL. */
|
|
private ObjectNode gameScopeResponse(GameScopeService.GameScopeResult result) {
|
|
ObjectNode response = objectMapper.createObjectNode();
|
|
response.put("status", result.status());
|
|
response.put("instruction", "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.");
|
|
response.put("question", result.question());
|
|
ArrayNode scopes = response.putArray("scopes");
|
|
for (GameScopeService.GameScope scope : result.scopes()) {
|
|
ObjectNode item = scopes.addObject();
|
|
item.put("gameKey", scope.gameKey());
|
|
item.put("displayName", scope.displayName());
|
|
item.put("matchedAlias", scope.matchedAlias());
|
|
item.put("status", scope.status());
|
|
item.put("reasonCode", scope.reasonCode());
|
|
item.put("aliasPriority", scope.aliasPriority());
|
|
item.put("scopeVersion", scope.scopeVersion());
|
|
item.put("nextAction", "SUPPORTED".equals(scope.status())
|
|
? "CALL_FEW_SHOT_NL2SQL" : "REPORT_UNSUPPORTED");
|
|
}
|
|
return response;
|
|
}
|
|
|
|
private ObjectNode tokenAccessDeniedResult() {
|
|
ObjectNode payload = objectMapper.createObjectNode();
|
|
payload.put("status", "VPD_TOKEN_DENIED");
|
|
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
|
|
|
|
ObjectNode result = objectMapper.createObjectNode();
|
|
ArrayNode content = objectMapper.createArrayNode();
|
|
ObjectNode text = objectMapper.createObjectNode();
|
|
text.put("type", "text");
|
|
text.put("text", pretty(payload));
|
|
content.add(text);
|
|
result.set("content", content);
|
|
result.put("isError", true);
|
|
return result;
|
|
}
|
|
|
|
private McpToolView selectAiQueryView() {
|
|
String profile = selectAiProfile();
|
|
return new McpToolView(
|
|
toolName(),
|
|
profile + " 프로파일로 " + toolDescription(),
|
|
-1L,
|
|
toolLabel(),
|
|
SELECT_AI_TOOL_PATH
|
|
);
|
|
}
|
|
|
|
private McpToolView selectAiShowpromptView() {
|
|
String profile = selectAiProfile();
|
|
return new McpToolView(
|
|
showpromptToolName(),
|
|
profile + " 프로파일로 " + showpromptToolDescription(),
|
|
-1L,
|
|
showpromptToolLabel(),
|
|
SELECT_AI_TOOL_PATH
|
|
);
|
|
}
|
|
|
|
private McpToolView qaVectorSearchView() {
|
|
return new McpToolView(
|
|
qaVectorSearchToolName(), qaVectorSearchToolDescription(), -1L,
|
|
qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private McpToolView qaVectorStoreView() {
|
|
return new McpToolView(
|
|
qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L,
|
|
qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private McpToolView fewShotNl2SqlView() {
|
|
return new McpToolView(
|
|
fewShotNl2SqlToolName(), fewShotNl2SqlToolDescription(), -1L,
|
|
fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private McpToolView gameScopeView() {
|
|
return new McpToolView(
|
|
gameScopeToolName(), gameScopeToolDescription(), -1L,
|
|
gameScopeToolLabel(), SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private McpToolView gameCatalogView() {
|
|
return new McpToolView(
|
|
GAME_CATALOG_TOOL,
|
|
"게임 카탈로그에서 질문의 게임 범위를 확인합니다. SQL은 실행하지 않습니다.",
|
|
-1L, "게임 카탈로그 범위 확인", SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private McpToolView gameQueryPlanView() {
|
|
return new McpToolView(GAME_QUERY_PLAN_TOOL,
|
|
"질문에서 게임 범위와 실행 모드를 판정합니다. SQL은 실행하지 않습니다.",
|
|
-1L, "게임 질의 계획", SELECT_AI_TOOL_PATH);
|
|
}
|
|
|
|
private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) {
|
|
ObjectNode response = gameCatalogVectorResponse(resolution);
|
|
response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "SUPPORTED");
|
|
String mode = switch (resolution.scopeType()) {
|
|
case "SINGLE_GAME" -> "SINGLE";
|
|
case "MULTI_GAME" -> "FAN_OUT";
|
|
case "ALL_GAMES" -> "GROUPED";
|
|
default -> "BLOCKED";
|
|
};
|
|
response.put("executionMode", mode);
|
|
return response;
|
|
}
|
|
|
|
private String selectAiProfile() {
|
|
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
|
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
|
|
return "";
|
|
}
|
|
return selectAi.profile().trim();
|
|
}
|
|
|
|
private String toolName() {
|
|
return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName();
|
|
}
|
|
|
|
private String toolLabel() {
|
|
return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel();
|
|
}
|
|
|
|
private String toolDescription() {
|
|
return mcpProperties == null
|
|
? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다."
|
|
: mcpProperties.resolvedToolDescription();
|
|
}
|
|
|
|
private String promptDescription() {
|
|
return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription();
|
|
}
|
|
|
|
private String showpromptToolName() {
|
|
return mcpProperties == null
|
|
? "oracle.select_ai.data_showprompt"
|
|
: mcpProperties.resolvedShowpromptToolName();
|
|
}
|
|
|
|
private String showpromptToolLabel() {
|
|
return mcpProperties == null
|
|
? "업무 데이터 SHOWPROMPT"
|
|
: mcpProperties.resolvedShowpromptToolLabel();
|
|
}
|
|
|
|
private String showpromptToolDescription() {
|
|
return mcpProperties == null
|
|
? "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다."
|
|
: mcpProperties.resolvedShowpromptToolDescription();
|
|
}
|
|
|
|
private String qaVectorSearchToolName() {
|
|
return mcpProperties == null ? "oracle.select_ai.qa_vector_search"
|
|
: mcpProperties.resolvedQaVectorSearchToolName();
|
|
}
|
|
|
|
private String qaVectorSearchToolLabel() {
|
|
return mcpProperties == null ? "Select AI 예제 SQL 조회"
|
|
: mcpProperties.resolvedQaVectorSearchToolLabel();
|
|
}
|
|
|
|
private String qaVectorSearchToolDescription() {
|
|
return mcpProperties == null
|
|
? "현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다."
|
|
: mcpProperties.resolvedQaVectorSearchToolDescription();
|
|
}
|
|
|
|
private String qaVectorStoreToolName() {
|
|
return mcpProperties == null ? "oracle.select_ai.qa_vector_store"
|
|
: mcpProperties.resolvedQaVectorStoreToolName();
|
|
}
|
|
|
|
private String qaVectorStoreToolLabel() {
|
|
return mcpProperties == null ? "Select AI 예제 SQL 저장"
|
|
: mcpProperties.resolvedQaVectorStoreToolLabel();
|
|
}
|
|
|
|
private String qaVectorStoreToolDescription() {
|
|
return mcpProperties == null
|
|
? "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다."
|
|
: mcpProperties.resolvedQaVectorStoreToolDescription();
|
|
}
|
|
|
|
private String fewShotNl2SqlToolName() {
|
|
return mcpProperties == null ? "oracle.select_ai.smilegate_fewshot_nl2sql"
|
|
: mcpProperties.resolvedFewShotNl2SqlToolName();
|
|
}
|
|
|
|
private String fewShotNl2SqlToolLabel() {
|
|
return mcpProperties == null ? "Few-shot NL2SQL 실행"
|
|
: mcpProperties.resolvedFewShotNl2SqlToolLabel();
|
|
}
|
|
|
|
private String fewShotNl2SqlToolDescription() {
|
|
return mcpProperties == null
|
|
? "벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다."
|
|
: mcpProperties.resolvedFewShotNl2SqlToolDescription();
|
|
}
|
|
|
|
private String gameScopeToolName() {
|
|
return mcpProperties == null ? "oracle.select_ai.game_scope_resolve"
|
|
: mcpProperties.resolvedGameScopeToolName();
|
|
}
|
|
|
|
private String gameScopeToolLabel() {
|
|
return mcpProperties == null ? "게임 조회 범위 확인" : mcpProperties.resolvedGameScopeToolLabel();
|
|
}
|
|
|
|
private String gameScopeToolDescription() {
|
|
return mcpProperties == null
|
|
? "질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다."
|
|
: mcpProperties.resolvedGameScopeToolDescription();
|
|
}
|
|
|
|
private String pretty(Object value) {
|
|
try {
|
|
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
|
} catch (Exception e) {
|
|
return String.valueOf(value);
|
|
}
|
|
}
|
|
}
|