[Developer] #424 namespace MCP SSE services by context path
This commit is contained in:
@@ -16,7 +16,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
return http
|
return http
|
||||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages"))
|
.csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages"))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class McpSseService {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObjectNode handle(JsonNode request) {
|
public ObjectNode handle(String contextPath, JsonNode request) {
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("jsonrpc", "2.0");
|
response.put("jsonrpc", "2.0");
|
||||||
if (request != null && request.has("id")) {
|
if (request != null && request.has("id")) {
|
||||||
@@ -37,7 +37,7 @@ public class McpSseService {
|
|||||||
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText();
|
||||||
try {
|
try {
|
||||||
response.set("result", switch (method) {
|
response.set("result", switch (method) {
|
||||||
case "initialize" -> initializeResult();
|
case "initialize" -> initializeResult(contextPath);
|
||||||
case "notifications/initialized" -> objectMapper.createObjectNode();
|
case "notifications/initialized" -> objectMapper.createObjectNode();
|
||||||
case "tools/list" -> toolsListResult();
|
case "tools/list" -> toolsListResult();
|
||||||
case "tools/call" -> toolsCallResult(request.path("params"));
|
case "tools/call" -> toolsCallResult(request.path("params"));
|
||||||
@@ -53,11 +53,11 @@ public class McpSseService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode initializeResult() {
|
private ObjectNode initializeResult(String contextPath) {
|
||||||
ObjectNode result = objectMapper.createObjectNode();
|
ObjectNode result = objectMapper.createObjectNode();
|
||||||
result.put("protocolVersion", "2024-11-05");
|
result.put("protocolVersion", "2024-11-05");
|
||||||
ObjectNode serverInfo = objectMapper.createObjectNode();
|
ObjectNode serverInfo = objectMapper.createObjectNode();
|
||||||
serverInfo.put("name", "vpd-ords-backoffice");
|
serverInfo.put("name", "vpd-ords-backoffice-" + contextPath);
|
||||||
serverInfo.put("version", "0.1.0");
|
serverInfo.put("version", "0.1.0");
|
||||||
result.set("serverInfo", serverInfo);
|
result.set("serverInfo", serverInfo);
|
||||||
ObjectNode capabilities = objectMapper.createObjectNode();
|
ObjectNode capabilities = objectMapper.createObjectNode();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
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 static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||||
|
|
||||||
private final McpSseService mcpSseService;
|
private final McpSseService mcpSseService;
|
||||||
private final Map<String, SseEmitter> sessions = new ConcurrentHashMap<>();
|
private final Map<String, McpSseSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public McpSseController(McpSseService mcpSseService) {
|
public McpSseController(McpSseService mcpSseService) {
|
||||||
this.mcpSseService = mcpSseService;
|
this.mcpSseService = mcpSseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@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();
|
String sessionId = UUID.randomUUID().toString();
|
||||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||||
sessions.put(sessionId, emitter);
|
sessions.put(sessionId, new McpSseSession(normalizedContextPath, emitter));
|
||||||
emitter.onCompletion(() -> sessions.remove(sessionId));
|
emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||||
emitter.onTimeout(() -> sessions.remove(sessionId));
|
emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||||
emitter.onError(error -> sessions.remove(sessionId));
|
emitter.onError(error -> sessions.remove(sessionId));
|
||||||
emitter.send(SseEmitter.event()
|
emitter.send(SseEmitter.event()
|
||||||
.name("endpoint")
|
.name("endpoint")
|
||||||
.data("/mcp/messages?sessionId=" + sessionId));
|
.data(messageEndpoint(normalizedContextPath, sessionId)));
|
||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/mcp/messages", consumes = MediaType.APPLICATION_JSON_VALUE)
|
private ResponseEntity<?> handleMessage(String contextPath, String sessionId, JsonNode request) throws IOException {
|
||||||
public ResponseEntity<?> message(
|
String normalizedContextPath = normalizeContextPath(contextPath);
|
||||||
@RequestParam(required = false) String sessionId,
|
ObjectNode response = mcpSseService.handle(normalizedContextPath, request);
|
||||||
@RequestBody JsonNode request
|
|
||||||
) throws IOException {
|
|
||||||
ObjectNode response = mcpSseService.handle(request);
|
|
||||||
if (sessionId == null || sessionId.isBlank()) {
|
if (sessionId == null || sessionId.isBlank()) {
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
SseEmitter emitter = sessions.get(sessionId);
|
McpSseSession session = sessions.get(sessionId);
|
||||||
if (emitter == null) {
|
if (session == null || !session.contextPath().equals(normalizedContextPath)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().name("message").data(response));
|
session.emitter().send(SseEmitter.event().name("message").data(response));
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
sessions.remove(sessionId);
|
sessions.remove(sessionId);
|
||||||
throw e;
|
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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<main class="container page-shell">
|
<main class="container page-shell">
|
||||||
<section class="page-heading">
|
<section class="page-heading">
|
||||||
<h1>MCP SSE</h1>
|
<h1>MCP SSE</h1>
|
||||||
<p>보호 객체 ORDS 조회를 외부 MCP client에서 호출할 수 있도록 SSE transport로 제공합니다.</p>
|
<p>보호 객체 ORDS 조회를 외부 MCP client에서 호출할 수 있도록 context path별 SSE transport로 제공합니다.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section th:if="${runtimeError}" class="alert alert-warning">
|
<section th:if="${runtimeError}" class="alert alert-warning">
|
||||||
@@ -20,13 +20,25 @@
|
|||||||
<table class="table align-middle">
|
<table class="table align-middle">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>SSE</th>
|
<th>Default SSE</th>
|
||||||
<td><code>/mcp/sse</code></td>
|
<td><code>/mcp/sse</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Message</th>
|
<th>Default Message</th>
|
||||||
<td><code>/mcp/messages?sessionId={sessionId}</code></td>
|
<td><code>/mcp/messages?sessionId={sessionId}</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Context SSE</th>
|
||||||
|
<td><code>/mcp/{contextPath}/sse</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Context Message</th>
|
||||||
|
<td><code>/mcp/{contextPath}/messages?sessionId={sessionId}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Context Path</th>
|
||||||
|
<td>영문/숫자로 시작하고 영문/숫자/<code>_</code>/<code>-</code>만 사용합니다. 예: <code>vpd</code>, <code>ords-prod</code></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Auth</th>
|
<th>Auth</th>
|
||||||
<td><code>Basic Auth</code> / <code>BACKOFFICE_ADMIN_USER</code>, <code>BACKOFFICE_ADMIN_PASSWORD</code></td>
|
<td><code>Basic Auth</code> / <code>BACKOFFICE_ADMIN_USER</code>, <code>BACKOFFICE_ADMIN_PASSWORD</code></td>
|
||||||
|
|||||||
Reference in New Issue
Block a user