From 28383b952d8acd14229c94c969e2e053d4a583aa Mon Sep 17 00:00:00 2001 From: devmrko Date: Thu, 25 Jun 2026 15:22:51 +0900 Subject: [PATCH] [Developer] #424 expose ORDS probes as MCP SSE tools --- .../vpdbackoffice/config/SecurityConfig.java | 3 + .../vpdbackoffice/service/McpSseService.java | 166 ++++++++++++++++++ .../web/McpReasoningController.java | 12 ++ .../vpdbackoffice/web/McpSseController.java | 67 +++++++ .../resources/templates/fragments/layout.html | 1 + src/main/resources/templates/mcp-sse.html | 77 ++++++++ 6 files changed, 326 insertions(+) create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java create mode 100644 src/main/resources/templates/mcp-sse.html diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index fbe4cb1..a12ee48 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -16,9 +16,12 @@ public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http + .csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages")) .authorizeHttpRequests(auth -> auth .requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll() .anyRequest().authenticated()) + .httpBasic(basic -> { + }) .formLogin(login -> login .loginPage("/login") .permitAll()) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java new file mode 100644 index 0000000..783c456 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -0,0 +1,166 @@ +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; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class McpSseService { + + private final McpToolRegistry toolRegistry; + private final OrdsProbeService ordsProbeService; + private final ObjectMapper objectMapper; + + public McpSseService( + McpToolRegistry toolRegistry, + OrdsProbeService ordsProbeService, + ObjectMapper objectMapper + ) { + this.toolRegistry = toolRegistry; + this.ordsProbeService = ordsProbeService; + this.objectMapper = objectMapper; + } + + public ObjectNode handle(JsonNode request) { + 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(); + try { + response.set("result", switch (method) { + case "initialize" -> initializeResult(); + case "notifications/initialized" -> objectMapper.createObjectNode(); + case "tools/list" -> toolsListResult(); + case "tools/call" -> toolsCallResult(request.path("params")); + 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; + } + + private ObjectNode initializeResult() { + ObjectNode result = objectMapper.createObjectNode(); + result.put("protocolVersion", "2024-11-05"); + ObjectNode serverInfo = objectMapper.createObjectNode(); + serverInfo.put("name", "vpd-ords-backoffice"); + 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(); + for (McpToolView tool : toolRegistry.listTools()) { + ObjectNode item = objectMapper.createObjectNode(); + item.put("name", tool.name()); + item.put("description", tool.description()); + item.set("inputSchema", inputSchema()); + tools.add(item); + } + result.set("tools", tools); + return result; + } + + private ObjectNode inputSchema() { + 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 limit = objectMapper.createObjectNode(); + limit.put("type", "integer"); + limit.put("description", "조회 row 제한. 1부터 500까지 허용"); + limit.put("minimum", 1); + limit.put("maximum", 500); + properties.set("limit", limit); + + schema.set("properties", properties); + ArrayNode required = objectMapper.createArrayNode(); + required.add("bearerToken"); + schema.set("required", required); + schema.put("additionalProperties", false); + return schema; + } + + private ObjectNode toolsCallResult(JsonNode params) { + String toolName = params.path("name").asText(""); + JsonNode arguments = params.path("arguments"); + McpToolView tool = findTool(toolName); + String bearerToken = arguments.path("bearerToken").asText(""); + int limit = normalizeLimit(arguments.path("limit").asInt(50)); + ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(tool.objectId(), bearerToken, limit)); + + 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 McpToolView findTool(String toolName) { + List tools = toolRegistry.listTools(); + return tools.stream() + .filter(tool -> tool.name().equals(toolName)) + .findFirst() + .orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName)); + } + + private int normalizeLimit(int limit) { + if (limit < 1) { + return 50; + } + return Math.min(limit, 500); + } + + private String pretty(Object value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (Exception e) { + return String.valueOf(value); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index 9617e21..13daef6 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -63,6 +63,18 @@ public class McpReasoningController { } } + @GetMapping("/mcp-sse") + public String ssePage(Model model) { + try { + model.addAttribute("tools", toolRegistry.listTools()); + } catch (DataAccessException e) { + RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e); + model.addAttribute("tools", List.of()); + model.addAttribute("runtimeError", message); + } + return "mcp-sse"; + } + @PostMapping("/mcp-reasoning") public String reason( @RequestParam long objectId, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java new file mode 100644 index 0000000..83b4ca3 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -0,0 +1,67 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.service.McpSseService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Controller +public class McpSseController { + + private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L; + + private final McpSseService mcpSseService; + private final Map sessions = new ConcurrentHashMap<>(); + + public McpSseController(McpSseService mcpSseService) { + this.mcpSseService = mcpSseService; + } + + @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter sse() throws IOException { + String sessionId = UUID.randomUUID().toString(); + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); + sessions.put(sessionId, emitter); + emitter.onCompletion(() -> sessions.remove(sessionId)); + emitter.onTimeout(() -> sessions.remove(sessionId)); + emitter.onError(error -> sessions.remove(sessionId)); + emitter.send(SseEmitter.event() + .name("endpoint") + .data("/mcp/messages?sessionId=" + sessionId)); + return emitter; + } + + @PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity message( + @RequestParam(required = false) String sessionId, + @RequestBody JsonNode request + ) throws IOException { + ObjectNode response = mcpSseService.handle(request); + if (sessionId == null || sessionId.isBlank()) { + return ResponseEntity.ok(response); + } + + SseEmitter emitter = sessions.get(sessionId); + if (emitter == null) { + return ResponseEntity.notFound().build(); + } + try { + emitter.send(SseEmitter.event().name("message").data(response)); + return ResponseEntity.accepted().build(); + } catch (IOException e) { + sessions.remove(sessionId); + throw e; + } + } +} diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 0e00edf..9c6f4c6 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -22,6 +22,7 @@ 토큰 ORDS 검증 MCP Reasoning + MCP SSE VPD 설정 ORDS 핸들러 설정 diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html new file mode 100644 index 0000000..b28ec00 --- /dev/null +++ b/src/main/resources/templates/mcp-sse.html @@ -0,0 +1,77 @@ + + + + + +
+
+

MCP SSE

+

보호 객체 ORDS 조회를 외부 MCP client에서 호출할 수 있도록 SSE transport로 제공합니다.

+
+ +
+ DB 연결 설정이 필요합니다. +
DB 연결을 확인할 수 없습니다.
+
+ +
+

Endpoint

+
+ + + + + + + + + + + + + + + + + + + +
SSE/mcp/sse
Message/mcp/messages?sessionId={sessionId}
AuthBasic Auth / BACKOFFICE_ADMIN_USER, BACKOFFICE_ADMIN_PASSWORD
Methodsinitialize, tools/list, tools/call
+
+
+ +
+

Tools

+
+ + + + + + + + + + + + + + + + + + +
NameObjectORDS Path
ords.query.admin.board_postsADMIN.BOARD_POSTScb-ords/cb-object-query/admin/board_posts
등록된 MCP tool이 없습니다.
+
+
+ +
+

tools/call Arguments

+
{
+  "bearerToken": "vpd_live_xxx",
+  "limit": 50
+}
+
+
+ +