merge: remote updates into main with tool-core schema updates
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m17s

This commit is contained in:
hjgram
2026-07-30 06:26:41 +09:00
11 changed files with 104 additions and 93 deletions

View File

@@ -16,9 +16,9 @@ jobs:
run: | run: |
if command -v apt-get &> /dev/null; then if command -v apt-get &> /dev/null; then
apt-get update apt-get update
apt-get install -y nodejs git openjdk-21-jdk apt-get install -y nodejs git openjdk-21-jdk rsync
elif command -v apk &> /dev/null; then elif command -v apk &> /dev/null; then
apk add --no-cache nodejs git openjdk21 apk add --no-cache nodejs git openjdk21 rsync
fi fi
- name: Checkout Code - name: Checkout Code
@@ -26,8 +26,13 @@ jobs:
- name: Sync Code to Host Volume - name: Sync Code to Host Volume
run: | run: |
echo "Copying latest code to /app (Host Volume)..." echo "Copying latest code to /app (Host Volume) and removing stale files..."
cp -a . /app/ if command -v rsync &> /dev/null; then
rsync -a --delete --exclude='.git' . /app/
else
rm -rf /app/dap-* /app/src /app/build.gradle /app/settings.gradle
cp -a . /app/
fi
- name: Deploy Task on Host - name: Deploy Task on Host
run: | run: |

View File

@@ -1,6 +1,6 @@
# DAP Backend # DAP Backend
Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트입니다. Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트 입니다.
--- ---

View File

@@ -261,7 +261,6 @@ public class ExecuteService {
if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) { if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) {
targetUrl = metadata.getPodUrl(); targetUrl = metadata.getPodUrl();
} }
String executeApiUrl = targetUrl + "/mcp/" + metadata.getName();
Map<String, String> headers = new java.util.HashMap<>(); Map<String, String> headers = new java.util.HashMap<>();
headers.put("trace-id", context.requestId()); headers.put("trace-id", context.requestId());
@@ -279,16 +278,16 @@ public class ExecuteService {
JsonNode data = null; JsonNode data = null;
try { try {
data = toolInvoker.invoke(pagePayload, executeApiUrl, headers); data = toolInvoker.invoke(metadata.getName(), pagePayload, targetUrl, headers);
} catch (org.springframework.web.client.RestClientResponseException e) { } catch (org.springframework.web.client.RestClientResponseException e) {
// HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환 // HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString()); throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString());
} catch (Exception e) { } catch (Exception e) {
if (executeApiUrl.contains("http://tool-")) { if (targetUrl.contains("http://tool-")) {
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); String fallbackUrl = targetUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl); log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
try { try {
data = toolInvoker.invoke(pagePayload, fallbackUrl, headers); data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers);
} catch (Exception ex) { } catch (Exception ex) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
} }
@@ -385,4 +384,4 @@ public class ExecuteService {
public void shutdown() { public void shutdown() {
executor.shutdownNow(); executor.shutdownNow();
} }
} }

View File

@@ -1,69 +1,81 @@
package io.shinhanlife.dap.mcg.transport; package io.shinhanlife.dap.mcg.transport;
/**
* @package io.shinhanlife.dap.mcg.transport
* @className HttpToolInvoker
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import com.fasterxml.jackson.databind.node.TextNode;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.Map; import java.util.Map;
import java.util.UUID; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/** MCP SDK client used for Gateway-to-Tool-Pod calls over Streamable HTTP. */
@Slf4j @Slf4j
@Component @Component
public class HttpToolInvoker implements ToolInvoker { public class HttpToolInvoker implements ToolInvoker {
private final RestClient restClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public HttpToolInvoker(ObjectMapper objectMapper) { public HttpToolInvoker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.restClient = RestClient.create();
} }
@Override @Override
public JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers) { public JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers) {
try { String endpoint = podUrl.replaceAll("/+$", "") + "/mcp";
RestClient.RequestBodySpec requestSpec = restClient.post() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
.uri(targetUrl) if (headers != null) {
.contentType(MediaType.APPLICATION_JSON); headers.forEach(requestBuilder::header);
}
if (headers != null) {
headers.forEach(requestSpec::header); HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(endpoint)
.requestBuilder(requestBuilder)
.connectTimeout(Duration.ofSeconds(5))
.build();
try (McpSyncClient client = McpClient.sync(transport)
.clientInfo(new McpSchema.Implementation("dap-gateway", "1.0.0"))
.requestTimeout(Duration.ofSeconds(30))
.build()) {
client.initialize();
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
.name(toolName)
.arguments(arguments)
.build());
if (Boolean.TRUE.equals(result.isError())) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool Pod MCP error: " + textContent(result));
} }
return result.structuredContent() != null
Object httpResult = requestSpec.body(payload) ? objectMapper.valueToTree(result.structuredContent())
.retrieve() : textContentAsJson(result);
.body(Object.class); } catch (ToolExecutionException error) {
throw error;
return extractData(objectMapper.valueToTree(httpResult)); } catch (Exception error) {
} catch (Exception e) { throw new ToolExecutionException(FailureType.SERVER_ERROR,
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e); "Tool Pod MCP call failed: " + error.getMessage(), error);
} }
} }
private JsonNode extractData(JsonNode root) { private JsonNode textContentAsJson(McpSchema.CallToolResult result) {
if (!root.path("success").asBoolean(true)) { String text = textContent(result);
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText()); try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
return TextNode.valueOf(text);
} }
return root.has("data") ? root.get("data") : root; }
private String textContent(McpSchema.CallToolResult result) {
return result.content().stream()
.filter(McpSchema.TextContent.class::isInstance)
.map(McpSchema.TextContent.class::cast)
.map(McpSchema.TextContent::text)
.findFirst()
.orElse("");
} }
} }

View File

@@ -22,5 +22,5 @@ import java.util.Map;
* Tool 서버 호출 transport의 최소 공통 인터페이스입니다. * Tool 서버 호출 transport의 최소 공통 인터페이스입니다.
*/ */
public interface ToolInvoker { public interface ToolInvoker {
JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers); JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers);
} }

View File

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

View File

@@ -16,10 +16,7 @@ package io.shinhanlife.dap.mcc.presentation;
* </pre> * </pre>
*/ */
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema; import com.networknt.schema.Error;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import io.shinhanlife.dap.lib.annotation.McpFunction; import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool; import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.lib.config.McpProperties;
@@ -58,6 +55,7 @@ public class BusinessToolController {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender; private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
// 내부 조회용 로컬 Tool 목록 엔드포인트 // 내부 조회용 로컬 Tool 목록 엔드포인트
@GetMapping("/mcp/api/v1/tools/local") @GetMapping("/mcp/api/v1/tools/local")
@@ -149,17 +147,12 @@ public class BusinessToolController {
if (!Map.class.isAssignableFrom(paramType)) { if (!Map.class.isAssignableFrom(paramType)) {
try { try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType); Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType);
String fullSchemaJson = objectMapper.writeValueAsString(autoSchema); List<Error> errors = toolArgumentSchemaValidator.validate(autoSchema, arguments);
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors); log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>(); List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) { for (Error validationError : errors) {
errorMessages.add(vm.getMessage()); errorMessages.add(validationError.getMessage());
} }
Map<String, Object> errorDetails = new HashMap<>(); Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422"); errorDetails.put("status", "422");
@@ -229,4 +222,4 @@ public class BusinessToolController {
return ResponseEntity.status(502).body(errorBody); return ResponseEntity.status(502).body(errorBody);
} }
} }
} }

View File

@@ -1,12 +1,12 @@
package io.shinhanlife.dap.mcc.presentation; package io.shinhanlife.dap.mcc.presentation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema; import com.networknt.schema.Error;
import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.InputFormat;
import com.networknt.schema.SpecVersion; import com.networknt.schema.Schema;
import com.networknt.schema.ValidationMessage; import com.networknt.schema.SchemaRegistry;
import java.util.Set; import com.networknt.schema.SpecificationVersion;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -20,11 +20,9 @@ public class ToolArgumentSchemaValidator {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public Set<ValidationMessage> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception { public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
JsonNode schemaNode = objectMapper.valueToTree(schemaDefinition); Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition));
JsonSchema schema = factory.getSchema(schemaNode); return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON);
JsonNode argumentsNode = objectMapper.valueToTree(arguments);
return schema.validate(argumentsNode);
} }
} }

View File

@@ -5,15 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema; import com.networknt.schema.Error;
import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.InputFormat;
import com.networknt.schema.SpecVersion; import com.networknt.schema.Schema;
import com.networknt.schema.ValidationMessage; import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import io.shinhanlife.dap.lib.annotation.McpParameter; import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation; import io.shinhanlife.dap.lib.annotation.McpValidation;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
/** /**
@@ -65,10 +65,10 @@ class JsonSchemaGeneratorTest {
@Test @Test
void validatorRejectsInvalidNestedValue() throws Exception { void validatorRejectsInvalidNestedValue() throws Exception {
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7)
.getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class))); .getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class)));
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(Map.of( List<Error> errors = schema.validate(objectMapper.writeValueAsString(Map.of(
"child", Map.of("businessDate", "2026-07-28")))); "child", Map.of("businessDate", "2026-07-28"))), InputFormat.JSON);
assertFalse(errors.isEmpty()); assertFalse(errors.isEmpty());
} }

View File

@@ -21,7 +21,7 @@ import lombok.Data;
@Data @Data
public class BalanceRequest { public class BalanceRequest {
@McpParameter(description = "고객의 계좌번호 (- 제외)", required = true) @McpParameter(description = "고객의 계좌번호 (- 제외) ", required = true)
@McpValidation(pattern = "\\S") @McpValidation(pattern = "\\S")
private String accountNumber; private String accountNumber;
} }

View File

@@ -36,4 +36,8 @@ axhub:
gateway: gateway:
url: http://localhost:8081 url: http://localhost:8081
tool: tool:
url: ${AXHUB_TOOL_URL:http://localhost:${server.port}} url: ${AXHUB_TOOL_URL:http://localhost:${server.port}}
sol:
req-detail:
mock-enabled: true