feat: OCI 배포 듀얼 LLM 스위칭 및 신규 툴 DTO 의존성 주입 병합

This commit is contained in:
hjgram
2026-07-30 06:19:06 +09:00
parent 752aae4dde
commit 204dd04d5f
28 changed files with 1059 additions and 21 deletions

View File

@@ -25,6 +25,7 @@ dependencies {
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:1.4.0'
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
api 'org.springframework.kafka:spring-kafka:3.2.0'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.mcc.mcp;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/** Exposes every Tool Pod through the MCP Streamable HTTP transport. */
@Configuration
public class ToolMcpServerConfiguration {
@Bean
@Primary
public HttpServletStreamableServerTransportProvider toolMcpTransportProvider() {
return HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.build();
}
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp");
}
}

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dap.mcc.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component
public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController;
private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) {
this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController;
this.objectMapper = objectMapper;
}
@EventListener(ApplicationReadyEvent.class)
public void registerLocalTools() {
heartbeatSender.getAllScannedTools().stream()
.filter(tool -> Boolean.TRUE.equals(tool.getVisible()))
.forEach(tool -> mcpServer.addTool(specification(tool)));
}
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema())
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build())
.build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), request.arguments())).build();
}
private McpSchema.CallToolResult invoke(String toolName, Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(toolName, null, null, null, arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build();
} catch (Exception error) {
return McpSchema.CallToolResult.builder().addTextContent(String.valueOf(body)).isError(failed).build();
}
}
private Map<String, Object> emptySchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", Map.of());
schema.put("additionalProperties", false);
return schema;
}
}

View File

@@ -0,0 +1,30 @@
package io.shinhanlife.dap.mcc.presentation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import java.util.Set;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */
@Component
public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper;
public ToolArgumentSchemaValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Set<ValidationMessage> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonNode schemaNode = objectMapper.valueToTree(schemaDefinition);
JsonSchema schema = factory.getSchema(schemaNode);
JsonNode argumentsNode = objectMapper.valueToTree(arguments);
return schema.validate(argumentsNode);
}
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.mcc.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
class ToolMcpServerConfigurationTest {
private final ToolMcpServerConfiguration configuration = new ToolMcpServerConfiguration();
@Test
void exposesOnlyExactMcpEndpointSoLegacyMcpApiPathsRemainAvailable() {
HttpServletStreamableServerTransportProvider transport = configuration.toolMcpTransportProvider();
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> registration = configuration.toolMcpServlet(transport);
assertEquals("/mcp", registration.getUrlMappings().iterator().next());
assertFalse(registration.getUrlMappings().contains("/mcp/*"));
}
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.dap.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ToolArgumentSchemaValidatorTest {
private final ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
@Test
void validatesDraft7SchemaWithTheRuntimeNetworkntVersion() throws Exception {
Map<String, Object> schema = Map.of(
"type", "object",
"properties", Map.of("name", Map.of("type", "string")),
"required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "Hong")).isEmpty());
assertFalse(validator.validate(schema, Map.of()).isEmpty());
}
}