package com.cloudhandson.ddsbackoffice.web; import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser; import com.cloudhandson.ddsbackoffice.service.DdsMcpBearerAuthenticator; import com.cloudhandson.ddsbackoffice.service.DdsMcpSseService; import com.cloudhandson.vpdbackoffice.service.AppException; 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.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /** * DDS-specific SSE transport. Authorization is deliberately rechecked for * every message rather than being trusted for the life of an SSE connection. */ @Controller public class DdsMcpSseController { private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L; private final DdsMcpBearerAuthenticator bearerAuthenticator; private final DdsMcpSseService service; private final Map sessions = new ConcurrentHashMap<>(); public DdsMcpSseController(DdsMcpBearerAuthenticator bearerAuthenticator, DdsMcpSseService service) { this.bearerAuthenticator = bearerAuthenticator; this.service = service; } @GetMapping(path = "/dds/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestHeader(name = "Authorization", required = false) String authorization) throws IOException { DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization); String sessionId = UUID.randomUUID().toString(); SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS); sessions.put(sessionId, new Session(user.applicationUserId(), emitter)); emitter.onCompletion(() -> sessions.remove(sessionId)); emitter.onTimeout(() -> sessions.remove(sessionId)); emitter.onError(error -> sessions.remove(sessionId)); emitter.send(SseEmitter.event().name("endpoint").data("/dds/mcp/messages?sessionId=" + sessionId)); return emitter; } @PostMapping(path = "/dds/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity message( @RequestHeader(name = "Authorization", required = false) String authorization, @RequestParam(required = false) String sessionId, @RequestBody JsonNode request ) throws IOException { DdsMcpAuthenticatedUser user = bearerAuthenticator.authenticate(authorization); ObjectNode response = service.handle(user, request); if (sessionId == null || sessionId.isBlank()) { return ResponseEntity.ok(response); } Session session = sessions.get(sessionId); if (session == null || session.applicationUserId() != user.applicationUserId()) { throw new AppException("AUTHORIZATION_DENIED: MCP 세션을 확인할 수 없습니다."); } try { session.emitter().send(SseEmitter.event().name("message").data(response)); return ResponseEntity.accepted().build(); } catch (IOException exception) { sessions.remove(sessionId); throw exception; } } private record Session(long applicationUserId, SseEmitter emitter) { } }