diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6a01b920..42a363a0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,9 +16,9 @@ jobs: run: | if command -v apt-get &> /dev/null; then 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 - apk add --no-cache nodejs git openjdk21 + apk add --no-cache nodejs git openjdk21 rsync fi - name: Checkout Code @@ -26,8 +26,13 @@ jobs: - name: Sync Code to Host Volume run: | - echo "Copying latest code to /app (Host Volume)..." - cp -a . /app/ + echo "Copying latest code to /app (Host Volume) and removing stale files..." + 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 run: | diff --git a/README.md b/README.md index 8d64e909..4028631a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DAP Backend -Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트입니다. +Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트 입니다. --- diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java index dfb7384f..e4c51559 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/service/ExecuteService.java @@ -261,7 +261,6 @@ public class ExecuteService { if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) { targetUrl = metadata.getPodUrl(); } - String executeApiUrl = targetUrl + "/mcp/" + metadata.getName(); Map headers = new java.util.HashMap<>(); headers.put("trace-id", context.requestId()); @@ -279,16 +278,16 @@ public class ExecuteService { JsonNode data = null; try { - data = toolInvoker.invoke(pagePayload, executeApiUrl, headers); + data = toolInvoker.invoke(metadata.getName(), pagePayload, targetUrl, headers); } catch (org.springframework.web.client.RestClientResponseException e) { // HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환 throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString()); } catch (Exception e) { - if (executeApiUrl.contains("http://tool-")) { - String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); + if (targetUrl.contains("http://tool-")) { + String fallbackUrl = targetUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl); try { - data = toolInvoker.invoke(pagePayload, fallbackUrl, headers); + data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers); } catch (Exception ex) { throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); } @@ -385,4 +384,4 @@ public class ExecuteService { public void shutdown() { executor.shutdownNow(); } -} \ No newline at end of file +} diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/HttpToolInvoker.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/HttpToolInvoker.java index 54445066..b6827dd5 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/HttpToolInvoker.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/HttpToolInvoker.java @@ -1,69 +1,81 @@ package io.shinhanlife.dap.mcg.transport; - -/** - * @package io.shinhanlife.dap.mcg.transport - * @className HttpToolInvoker - * @description AX HUB 시스템 처리 클래스 - * @author 0986406 - * @create 2026.09.01 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
- */ -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.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.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 @Component public class HttpToolInvoker implements ToolInvoker { - private final RestClient restClient; private final ObjectMapper objectMapper; public HttpToolInvoker(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.restClient = RestClient.create(); } @Override - public JsonNode invoke(Map payload, String targetUrl, Map headers) { - try { - RestClient.RequestBodySpec requestSpec = restClient.post() - .uri(targetUrl) - .contentType(MediaType.APPLICATION_JSON); - - if (headers != null) { - headers.forEach(requestSpec::header); + public JsonNode invoke(String toolName, Map arguments, String podUrl, Map headers) { + String endpoint = podUrl.replaceAll("/+$", "") + "/mcp"; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(); + if (headers != null) { + headers.forEach(requestBuilder::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)); } - - Object httpResult = requestSpec.body(payload) - .retrieve() - .body(Object.class); - - return extractData(objectMapper.valueToTree(httpResult)); - } catch (Exception e) { - throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e); + return result.structuredContent() != null + ? objectMapper.valueToTree(result.structuredContent()) + : textContentAsJson(result); + } catch (ToolExecutionException error) { + throw error; + } catch (Exception error) { + throw new ToolExecutionException(FailureType.SERVER_ERROR, + "Tool Pod MCP call failed: " + error.getMessage(), error); } } - private JsonNode extractData(JsonNode root) { - if (!root.path("success").asBoolean(true)) { - throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText()); + private JsonNode textContentAsJson(McpSchema.CallToolResult result) { + String text = textContent(result); + 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(""); } } diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/ToolInvoker.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/ToolInvoker.java index 74e2db22..649638d1 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/ToolInvoker.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/transport/ToolInvoker.java @@ -22,5 +22,5 @@ import java.util.Map; * Tool 서버 호출 transport의 최소 공통 인터페이스입니다. */ public interface ToolInvoker { - JsonNode invoke(Map payload, String targetUrl, Map headers); + JsonNode invoke(String toolName, Map arguments, String podUrl, Map headers); } diff --git a/dap-tool-core/build.gradle b/dap-tool-core/build.gradle index fd52e4cd..2c803d59 100644 --- a/dap-tool-core/build.gradle +++ b/dap-tool-core/build.gradle @@ -24,7 +24,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 'com.networknt:json-schema-validator:3.0.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' diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java index 3c722d3d..64bde2c2 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java @@ -16,10 +16,7 @@ package io.shinhanlife.dap.mcc.presentation; * */ 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 com.networknt.schema.Error; import io.shinhanlife.dap.lib.annotation.McpFunction; import io.shinhanlife.dap.lib.annotation.McpTool; import io.shinhanlife.dap.lib.config.McpProperties; @@ -58,6 +55,7 @@ public class BusinessToolController { private final ObjectMapper objectMapper; private final McpProperties mcpProperties; private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender; + private final ToolArgumentSchemaValidator toolArgumentSchemaValidator; // 내부 조회용 로컬 Tool 목록 엔드포인트 @GetMapping("/mcp/api/v1/tools/local") @@ -149,17 +147,12 @@ public class BusinessToolController { if (!Map.class.isAssignableFrom(paramType)) { try { Map autoSchema = JsonSchemaGenerator.generateSchema(paramType); - String fullSchemaJson = objectMapper.writeValueAsString(autoSchema); - - JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); - JsonSchema schema = factory.getSchema(fullSchemaJson); - - Set errors = schema.validate(objectMapper.valueToTree(arguments)); + List errors = toolArgumentSchemaValidator.validate(autoSchema, arguments); if (!errors.isEmpty()) { log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors); List errorMessages = new ArrayList<>(); - for (ValidationMessage vm : errors) { - errorMessages.add(vm.getMessage()); + for (Error validationError : errors) { + errorMessages.add(validationError.getMessage()); } Map errorDetails = new HashMap<>(); errorDetails.put("status", "422"); @@ -229,4 +222,4 @@ public class BusinessToolController { return ResponseEntity.status(502).body(errorBody); } } -} \ No newline at end of file +} diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java index dc8cfeab..876144ca 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java @@ -1,12 +1,12 @@ 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 com.networknt.schema.Error; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Component; @@ -20,11 +20,9 @@ public class ToolArgumentSchemaValidator { this.objectMapper = objectMapper; } - public Set validate(Map schemaDefinition, Map 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); + public List validate(Map schemaDefinition, Map arguments) throws Exception { + SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); + Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition)); + return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON); } } diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java index c7e648a8..55eaf59e 100644 --- a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java @@ -5,15 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; 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 com.networknt.schema.Error; +import com.networknt.schema.InputFormat; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SpecificationVersion; import io.shinhanlife.dap.lib.annotation.McpParameter; import io.shinhanlife.dap.lib.annotation.McpValidation; import java.util.List; import java.util.Map; -import java.util.Set; import org.junit.jupiter.api.Test; /** @@ -65,10 +65,10 @@ class JsonSchemaGeneratorTest { @Test void validatorRejectsInvalidNestedValue() throws Exception { 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))); - Set errors = schema.validate(objectMapper.valueToTree(Map.of( - "child", Map.of("businessDate", "2026-07-28")))); + List errors = schema.validate(objectMapper.writeValueAsString(Map.of( + "child", Map.of("businessDate", "2026-07-28"))), InputFormat.JSON); assertFalse(errors.isEmpty()); } diff --git a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/BalanceRequest.java b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/BalanceRequest.java index a16c0c7f..1e8df091 100644 --- a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/BalanceRequest.java +++ b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/BalanceRequest.java @@ -21,7 +21,7 @@ import lombok.Data; @Data public class BalanceRequest { - @McpParameter(description = "고객의 계좌번호 (- 제외)", required = true) + @McpParameter(description = "고객의 계좌번호 (- 제외) ", required = true) @McpValidation(pattern = "\\S") private String accountNumber; } diff --git a/dap-tool-oth/src/main/resources/application-local.yml b/dap-tool-oth/src/main/resources/application-local.yml index 13593bf4..a4e5f24c 100644 --- a/dap-tool-oth/src/main/resources/application-local.yml +++ b/dap-tool-oth/src/main/resources/application-local.yml @@ -36,4 +36,8 @@ axhub: gateway: url: http://localhost:8081 tool: - url: ${AXHUB_TOOL_URL:http://localhost:${server.port}} \ No newline at end of file + url: ${AXHUB_TOOL_URL:http://localhost:${server.port}} + +sol: + req-detail: + mock-enabled: true