diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index a12ee48..06ffc8f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -16,7 +16,7 @@ public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http - .csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages")) + .csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages")) .authorizeHttpRequests(auth -> auth .requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 783c456..25cf700 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -27,7 +27,7 @@ public class McpSseService { this.objectMapper = objectMapper; } - public ObjectNode handle(JsonNode request) { + public ObjectNode handle(String contextPath, JsonNode request) { ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { @@ -37,7 +37,7 @@ public class McpSseService { String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); try { response.set("result", switch (method) { - case "initialize" -> initializeResult(); + case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); case "tools/call" -> toolsCallResult(request.path("params")); @@ -53,11 +53,11 @@ public class McpSseService { return response; } - private ObjectNode initializeResult() { + 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"); + serverInfo.put("name", "vpd-ords-backoffice-" + contextPath); serverInfo.put("version", "0.1.0"); result.set("serverInfo", serverInfo); ObjectNode capabilities = objectMapper.createObjectNode(); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java index 83b4ca3..81c54a0 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpSseController.java @@ -11,6 +11,7 @@ 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -22,46 +23,88 @@ public class McpSseController { private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L; private final McpSseService mcpSseService; - private final Map sessions = new ConcurrentHashMap<>(); + 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 { + public SseEmitter defaultSse() throws IOException { + return openSse("default"); + } + + @GetMapping(path = "/mcp/{contextPath}/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter contextSse(@PathVariable String contextPath) throws IOException { + return openSse(contextPath); + } + + @PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity defaultMessage( + @RequestParam(required = false) String sessionId, + @RequestBody JsonNode request + ) throws IOException { + return handleMessage("default", sessionId, request); + } + + @PostMapping(path = "/mcp/{contextPath}/messages", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity contextMessage( + @PathVariable String contextPath, + @RequestParam(required = false) String sessionId, + @RequestBody JsonNode request + ) throws IOException { + return handleMessage(contextPath, sessionId, request); + } + + private SseEmitter openSse(String contextPath) throws IOException { + String normalizedContextPath = normalizeContextPath(contextPath); String sessionId = UUID.randomUUID().toString(); SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); - sessions.put(sessionId, emitter); + sessions.put(sessionId, new McpSseSession(normalizedContextPath, 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)); + .data(messageEndpoint(normalizedContextPath, 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); + private ResponseEntity handleMessage(String contextPath, String sessionId, JsonNode request) throws IOException { + String normalizedContextPath = normalizeContextPath(contextPath); + ObjectNode response = mcpSseService.handle(normalizedContextPath, request); if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } - SseEmitter emitter = sessions.get(sessionId); - if (emitter == null) { + McpSseSession session = sessions.get(sessionId); + if (session == null || !session.contextPath().equals(normalizedContextPath)) { return ResponseEntity.notFound().build(); } try { - emitter.send(SseEmitter.event().name("message").data(response)); + session.emitter().send(SseEmitter.event().name("message").data(response)); return ResponseEntity.accepted().build(); } catch (IOException e) { sessions.remove(sessionId); throw e; } } + + private String messageEndpoint(String contextPath, String sessionId) { + if ("default".equals(contextPath)) { + return "/mcp/messages?sessionId=" + sessionId; + } + return "/mcp/" + contextPath + "/messages?sessionId=" + sessionId; + } + + private String normalizeContextPath(String contextPath) { + String normalized = contextPath == null || contextPath.isBlank() ? "default" : contextPath.trim(); + if (!normalized.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) { + throw new IllegalArgumentException("MCP context path는 영문/숫자로 시작하고 영문/숫자/_/-만 사용할 수 있습니다: " + contextPath); + } + return normalized.toLowerCase(); + } + + private record McpSseSession(String contextPath, SseEmitter emitter) { + } } diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html index b28ec00..1da355a 100644 --- a/src/main/resources/templates/mcp-sse.html +++ b/src/main/resources/templates/mcp-sse.html @@ -6,7 +6,7 @@

MCP SSE

-

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

+

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

@@ -20,13 +20,25 @@ - + - + + + + + + + + + + + + +
SSEDefault SSE /mcp/sse
MessageDefault Message /mcp/messages?sessionId={sessionId}
Context SSE/mcp/{contextPath}/sse
Context Message/mcp/{contextPath}/messages?sessionId={sessionId}
Context Path영문/숫자로 시작하고 영문/숫자/_/-만 사용합니다. 예: vpd, ords-prod
Auth Basic Auth / BACKOFFICE_ADMIN_USER, BACKOFFICE_ADMIN_PASSWORD