[Developer] #424 namespace MCP SSE services by context path

This commit is contained in:
devmrko
2026-06-25 15:27:41 +09:00
parent 28383b952d
commit c8e1bcef42
4 changed files with 76 additions and 21 deletions

View File

@@ -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<String, SseEmitter> sessions = new ConcurrentHashMap<>();
private final Map<String, McpSseSession> 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) {
}
}