refactor: apply Option B, remove heartbeat and register tools once on startup
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 44s

This commit is contained in:
jade
2026-08-13 17:34:32 +09:00
parent 34e678ad63
commit 8f1c64ac58
177 changed files with 718 additions and 170 deletions

View File

@@ -37,15 +37,19 @@ dependencies {
api 'io.lettuce:lettuce-core:6.6.0.RELEASE'
// MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다.
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:3.0.0'
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml'
api 'com.fasterxml.jackson.core:jackson-databind'
// MCP Java SDK 2.0.0의 mcp-json-jackson2가 호출하는 Schema API와 반드시 맞춘다.
// 3.x는 Schema.validate(JsonNode) 반환 규격이 달라 런타임 NoSuchMethodError가 발생한다.
api 'com.networknt:json-schema-validator:2.0.0'
// Tool Pod의 /mcp Streamable HTTP Server와 MCP SDK를 제공합니다.
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
// Boot 3.5.11 위에서 MCP Java SDK 2.0.0의 Server/Client와 Servlet 전송 계층을 사용합니다.
// Jackson 2 공급자를 명시해 Boot 3의 Jackson 계열과 맞춥니다.
api 'io.modelcontextprotocol.sdk:mcp-core'
api 'io.modelcontextprotocol.sdk:mcp-json-jackson2'
// Spring AI 1.1.x에서 @McpTool, @McpToolParam, metaProvider를 제공합니다.
// Boot 3.5 호환 Spring AI 어노테이션 API를 유지합니다.
api 'org.springaicommunity:mcp-annotations:0.9.0'
// 이벤트 기반 확장이 필요한 Tool의 공통 Kafka 연동 기능입니다.

View File

@@ -1,6 +1,9 @@
package io.shinhanlife.dap.lib.mcp;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -28,6 +31,15 @@ public class ToolMcpServerConfiguration {
.build();
}
/** Creates the Tool Pod MCP server directly with MCP Java SDK 2.0.0. */
@Bean(destroyMethod = "close")
public McpSyncServer toolMcpServer(HttpServletStreamableServerTransportProvider transportProvider) {
return McpServer.sync(transportProvider)
.serverInfo("dap-tool-pod", "2.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.build();
}
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {

View File

@@ -24,6 +24,9 @@ import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
@@ -110,12 +113,12 @@ public class ToolRegistryHeartbeatSender {
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + rawSubToolName
: rawSubToolName;
// @GrowToolHint(register = false)인 Tool은 메타데이터 조회에는 남기되,
// @GrowToolHint인 Tool은 메타데이터 조회에는 남기되,
// Gateway 등록 및 heartbeat 전송 대상에서는 제외합니다.
// ToolHint가 없는 기존 Tool은 이전 동작과 동일하게 등록합니다.
boolean isRegister = hintAnnotation == null || hintAnnotation.register();
if (!isRegister) {
log.info(" [HeartbeatSender] '{}' Tool is excluded from Gateway registration because register=false. (tool name: {})",
log.info(" [HeartbeatSender] '{}' Tool is excluded from Gateway registration because . (tool name: {})",
baseName, subToolName);
}
@@ -223,26 +226,30 @@ public class ToolRegistryHeartbeatSender {
meta.setOwnerOrg(definition.ownerOrg());
}
@Scheduled(fixedRate = 30000)
public void sendHeartbeats() {
@EventListener(ApplicationReadyEvent.class)
public void registerAllTools() {
if (registeredTools.isEmpty()) return;
for (ToolMetadata tool : registeredTools) {
registerTool(tool);
}
}
@PreDestroy
public void deregisterAllTools() {
if (registeredTools.isEmpty()) return;
for (ToolMetadata tool : registeredTools) {
try {
ResponseEntity<String> response = restClient.post()
.uri(gatewayUrl + "/mcp/api/v1/registry/heartbeat")
restClient.post()
.uri(gatewayUrl + "/mcp/api/v1/registry/deregister")
.header("Content-Type", "application/json")
.body(tool.getUid())
.retrieve()
.toEntity(String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info(" [HeartbeatSender] 하트비트 전송 성공: {}", tool.getUid());
}
} catch (Exception e) {
log.warn(" [HeartbeatSender] 하트비트 전송 실패 ({}): {}. 재등록을 시도합니다.", tool.getUid(), e.getMessage());
registerTool(tool);
.toBodilessEntity();
log.info(" [HeartbeatSender] 툴 삭제(Deregister) 성공: {}", tool.getUid());
} catch (Exception ex) {
log.warn(" [HeartbeatSender] 툴 삭제 실패: {}", ex.getMessage());
}
}
}

View File

@@ -337,13 +337,8 @@ public class PodScaffolder {
dockerfile: %s/Dockerfile
ports:
- "%s:%s"
depends_on:
- redis
environment:
- TZ=Asia/Seoul
- SPRING_REDIS_HOST=redis
- SPRING_REDIS_PORT=6379
- SPRING_DATA_REDIS_PORT=6379
- AXHUB_GATEWAY_URL=http://gateway:8081
- AXHUB_TOOL_URL=http://%s:%s
- GLOW_COMMUNICATION_MCI_HOST=http://mci-mock

View File

@@ -425,9 +425,9 @@ public class ToolScaffolder {
if (routingType.trim().isEmpty()) {
routingType = "HTTP";
}
String moduleName = getOrAsk(args, 6, scanner, "7. Target module (default dap-was-oth): ");
String moduleName = getOrAsk(args, 6, scanner, "7. Target module (default dap-was-cus): ");
if (moduleName.trim().isEmpty()) {
moduleName = "dap-was-oth";
moduleName = "dap-was-cus";
}
String defaultAuthor = System.getProperty("user.name");

View File

@@ -1,20 +1,29 @@
package io.shinhanlife.dap.lib.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.annotation.McpTool;
class McpSdk2CompatibilityTest {
@Test
void usesOfficialSpringAiMcpAnnotations() {
assertEquals("org.springframework.ai.mcp.annotation", McpTool.class.getPackageName());
}
@Test
void usesMcpJavaSdkTwo() {
assertEquals("2.0.0", McpSchema.class.getPackage().getImplementationVersion());
}
@Test
void validatesJsonSchemaWithTheNetworkntVersionRequiredByMcpSdkTwo() {
DefaultJsonSchemaValidator validator = new DefaultJsonSchemaValidator();
var response = validator.validateSchema(Map.of(
"$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12,
"type", "object",
"properties", Map.of("employeeId", Map.of("type", "string"))));
assertTrue(response.valid(), response.errorMessage());
}
}

View File

@@ -50,4 +50,17 @@ class PodScaffolderTest {
assertTrue(Files.readString(resources.resolve("application-prod.yml"))
.contains("classpath:glow/application-glow-prod.yml"));
}
@Test
void generatesRedisOptionalPodComposeConfiguration() throws Exception {
Files.writeString(root.resolve("settings.gradle"), "rootProject.name = 'test'\n");
Files.writeString(root.resolve("docker-compose.yml"), "services:\n");
PodScaffolder.scaffoldPod(root, "dap-was-pro", "8085", "pro", "tester", "2026.08.13");
String compose = Files.readString(root.resolve("docker-compose.yml"));
assertTrue(compose.contains("was-pro:"));
assertFalse(compose.contains("depends_on:\n - redis"));
assertFalse(compose.contains("SPRING_REDIS_HOST=redis"));
}
}

View File

@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.assertj.core.api.Assertions.assertThat;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.McpSyncServer;
import io.shinhanlife.dap.lib.mcp.ToolMcpServerConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
@@ -20,4 +21,14 @@ class ToolMcpServerConfigurationTest {
assertThat(registration.getUrlMappings()).containsExactlyInAnyOrder("/mcp", "/mcp/message");
}
@Test
void createsMcpSdkTwoSyncServerWithoutSpringAiAutoConfiguration() {
HttpServletStreamableServerTransportProvider transport = configuration.toolMcpTransportProvider();
McpSyncServer server = configuration.toolMcpServer(transport);
assertEquals("dap-tool-pod", server.getServerInfo().name());
server.close();
}
}