Consolidate data access control backoffice updates

This commit is contained in:
devmrko
2026-07-13 23:06:23 +09:00
parent 403298d474
commit e18b30feab
181 changed files with 11571 additions and 954 deletions

View File

@@ -1,8 +1,6 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -10,30 +8,41 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.List;
import org.springframework.stereotype.Service;
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
@Service
public class McpSseService {
private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router";
private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run";
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
private static final String SELECT_AI_VPD_QUERY_PROFILE =
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
SELECT_AI_VPD_QUERY_TOOL,
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
-1L,
"KB Select AI 행 접근 자연어 조회",
SELECT_AI_VPD_QUERY_PATH
);
private final McpToolRegistry toolRegistry;
private final OrdsProbeService ordsProbeService;
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final ObjectMapper objectMapper;
public McpSseService(
McpToolRegistry toolRegistry,
OrdsProbeService ordsProbeService,
SelectAiAgentOrdsService selectAiAgentOrdsService,
ObjectMapper objectMapper
) {
this.toolRegistry = toolRegistry;
this.ordsProbeService = ordsProbeService;
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.objectMapper = objectMapper;
}
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")) {
@@ -41,12 +50,13 @@ public class McpSseService {
}
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(request.path("params"));
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
});
} catch (Exception e) {
@@ -59,6 +69,11 @@ public class McpSseService {
return response;
}
/** The only tool registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(SELECT_AI_VPD_QUERY_VIEW);
}
private ObjectNode initializeResult(String contextPath) {
ObjectNode result = objectMapper.createObjectNode();
result.put("protocolVersion", "2024-11-05");
@@ -75,83 +90,35 @@ public class McpSseService {
private ObjectNode toolsListResult() {
ObjectNode result = objectMapper.createObjectNode();
ArrayNode tools = objectMapper.createArrayNode();
for (McpToolView tool : toolRegistry.listTools()) {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", tool.name());
item.put("description", tool.description());
item.set("inputSchema", inputSchema(tool));
tools.add(item);
}
tools.add(selectAiRouterTool());
tools.add(selectAiVpdQueryTool());
result.set("tools", tools);
return result;
}
private ObjectNode inputSchema(McpToolView tool) {
private ObjectNode selectAiVpdQueryTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
ObjectNode limit = objectMapper.createObjectNode();
limit.put("type", "integer");
limit.put("description", "조회 row 제한. 1부터 500까지 허용");
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
limit.put("minimum", 1);
limit.put("maximum", 500);
limit.put("maximum", 100);
properties.set("limit", limit);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
if (isVectorTool(tool)) {
ObjectNode embedding = objectMapper.createObjectNode();
embedding.put("type", "array");
embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 개발 환경에서는 4차원 벡터를 사용합니다.");
ObjectNode items = objectMapper.createObjectNode();
items.put("type", "number");
embedding.set("items", items);
embedding.put("minItems", 1);
properties.set("embedding", embedding);
required.add("embedding");
}
schema.set("required", required);
schema.put("additionalProperties", false);
return schema;
}
private ObjectNode selectAiRouterTool() {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", SELECT_AI_ROUTER_TOOL);
item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다.");
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode bearerToken = objectMapper.createObjectNode();
bearerToken.put("type", "string");
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
properties.set("bearerToken", bearerToken);
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다.");
prompt.put("maxLength", 8000);
properties.set("prompt", prompt);
ObjectNode conversationId = objectMapper.createObjectNode();
conversationId.put("type", "string");
conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자");
conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$");
properties.set("conversationId", conversationId);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
required.add("prompt");
schema.set("required", required);
schema.put("additionalProperties", false);
@@ -159,67 +126,32 @@ public class McpSseService {
return item;
}
private ObjectNode toolsCallResult(JsonNode params) {
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
String toolName = params.path("name").asText("");
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
}
JsonNode arguments = params.path("arguments");
if (SELECT_AI_ROUTER_TOOL.equals(toolName)) {
return selectAiRouterCallResult(arguments);
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
if (token.isBlank()) {
return tokenAccessDeniedResult();
}
McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50));
String requestBody = null;
if (isVectorTool(tool)) {
JsonNode embedding = arguments.get("embedding");
if (embedding == null || !embedding.isArray() || embedding.isEmpty()) {
throw new AppException("벡터 검색 tool에는 embedding 배열이 필요합니다.");
}
ObjectNode body = objectMapper.createObjectNode();
body.set("embedding", embedding);
requestBody = body.toString();
JsonNode response;
try {
response = selectAiAgentOrdsService.run(
token,
arguments.path("prompt").asText(""),
normalizeLimit(arguments.path("limit").asInt(50))
);
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
ProbeResult probeResult = ordsProbeService.runProbe(
new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody));
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name());
payload.put("objectId", tool.objectId());
payload.put("object", tool.displayName());
payload.put("ordsPath", tool.ordsPath());
payload.put("status", probeResult.status().name());
payload.put("rowCount", probeResult.rowCount());
payload.set("columns", objectMapper.valueToTree(probeResult.columns()));
payload.set("maskedColumns", objectMapper.valueToTree(probeResult.maskedColumns()));
payload.set("rows", objectMapper.valueToTree(probeResult.rows()));
payload.put("errorCode", probeResult.errorCode());
payload.put("errorMessage", probeResult.errorMessage());
payload.put("requestHeaders", probeResult.requestHeaders());
payload.put("requestPayload", probeResult.requestPayload());
payload.put("responseHeaders", probeResult.responseHeaders());
payload.put("responseBody", probeResult.responseBody());
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", probeResult.errorCode() != null);
return result;
}
private ObjectNode selectAiRouterCallResult(JsonNode arguments) {
JsonNode response = selectAiAgentOrdsService.run(
arguments.path("bearerToken").asText(""),
arguments.path("prompt").asText(""),
arguments.path("conversationId").asText("")
);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", SELECT_AI_ROUTER_TOOL);
payload.put("team", "KB_SELECT_AI_ROUTER_TEAM");
payload.put("ordsPath", SELECT_AI_ROUTER_PATH);
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
payload.set("response", response);
ObjectNode result = objectMapper.createObjectNode();
@@ -233,23 +165,27 @@ public class McpSseService {
return result;
}
private McpToolView findTool(String toolName) {
List<McpToolView> tools = toolRegistry.listTools();
return tools.stream()
.filter(tool -> tool.name().equals(toolName))
.findFirst()
.orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName));
}
private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED");
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
private boolean isVectorTool(McpToolView tool) {
return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS");
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 int normalizeLimit(int limit) {
if (limit < 1) {
return 50;
}
return Math.min(limit, 500);
return Math.min(limit, 100);
}
private String pretty(Object value) {