[Developer] #424 expose ORDS probes as MCP SSE tools
This commit is contained in:
@@ -16,9 +16,12 @@ 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"))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
|
.httpBasic(basic -> {
|
||||||
|
})
|
||||||
.formLogin(login -> login
|
.formLogin(login -> login
|
||||||
.loginPage("/login")
|
.loginPage("/login")
|
||||||
.permitAll())
|
.permitAll())
|
||||||
|
|||||||
@@ -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<McpToolView> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
@PostMapping("/mcp-reasoning")
|
||||||
public String reason(
|
public String reason(
|
||||||
@RequestParam long objectId,
|
@RequestParam long objectId,
|
||||||
|
|||||||
@@ -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<String, SseEmitter> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<a class="nav-link" href="/tokens">토큰</a>
|
<a class="nav-link" href="/tokens">토큰</a>
|
||||||
<a class="nav-link" href="/probe">ORDS 검증</a>
|
<a class="nav-link" href="/probe">ORDS 검증</a>
|
||||||
<a class="nav-link" href="/mcp-reasoning">MCP Reasoning</a>
|
<a class="nav-link" href="/mcp-reasoning">MCP Reasoning</a>
|
||||||
|
<a class="nav-link" href="/mcp-sse">MCP SSE</a>
|
||||||
<a class="nav-link" href="/vpd-policies">VPD 설정</a>
|
<a class="nav-link" href="/vpd-policies">VPD 설정</a>
|
||||||
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
|
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
|
||||||
<a class="nav-link" href="/settings">설정</a>
|
<a class="nav-link" href="/settings">설정</a>
|
||||||
|
|||||||
77
src/main/resources/templates/mcp-sse.html
Normal file
77
src/main/resources/templates/mcp-sse.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head th:replace="~{fragments/layout :: head('MCP SSE')}"></head>
|
||||||
|
<body>
|
||||||
|
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||||
|
<main class="container page-shell">
|
||||||
|
<section class="page-heading">
|
||||||
|
<h1>MCP SSE</h1>
|
||||||
|
<p>보호 객체 ORDS 조회를 외부 MCP client에서 호출할 수 있도록 SSE transport로 제공합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section th:if="${runtimeError}" class="alert alert-warning">
|
||||||
|
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
|
||||||
|
<div th:text="${runtimeError.message()}">DB 연결을 확인할 수 없습니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<h2>Endpoint</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>SSE</th>
|
||||||
|
<td><code>/mcp/sse</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Message</th>
|
||||||
|
<td><code>/mcp/messages?sessionId={sessionId}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Auth</th>
|
||||||
|
<td><code>Basic Auth</code> / <code>BACKOFFICE_ADMIN_USER</code>, <code>BACKOFFICE_ADMIN_PASSWORD</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Methods</th>
|
||||||
|
<td><code>initialize</code>, <code>tools/list</code>, <code>tools/call</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<h2>Tools</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Object</th>
|
||||||
|
<th>ORDS Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr th:each="tool : ${tools}">
|
||||||
|
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
||||||
|
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
|
||||||
|
<td><code th:text="${tool.ordsPath()}">cb-ords/cb-object-query/admin/board_posts</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(tools)}">
|
||||||
|
<td colspan="3" class="text-muted">등록된 MCP tool이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-band">
|
||||||
|
<h2>tools/call Arguments</h2>
|
||||||
|
<pre class="code-block">{
|
||||||
|
"bearerToken": "vpd_live_xxx",
|
||||||
|
"limit": 50
|
||||||
|
}</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user