From 2d730ea9c113cabacbe0a3c85ffbf27c00573aa9 Mon Sep 17 00:00:00 2001 From: janghw Date: Tue, 8 Sep 2026 13:56:13 +0900 Subject: [PATCH] =?UTF-8?q?local-fixtures=20=EC=B6=94=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=20tool=20server=20json=ED=8C=8C=EC=9D=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/local-tool-responses-sample-v1.json | 28 ++++ .../manifests/was-cus-manifest-sample-v1.json | 38 +++++ .../manifests/was-pro-manifest-sample-v1.json | 40 ++++++ .../manifests/was-sal-manifest-sample-v1.json | 40 ++++++ .../manifests/was-sys-manifest-sample-v1.json | 34 +++++ .../mcp/config/LocalFixtureProperties.java | 49 +++++++ .../registry/LocalFileToolRegistryClient.java | 5 +- .../registry/PortalToolRegistryClient.java | 15 +- .../biz/mcp/registry/ToolBundleDiscovery.java | 26 +++- .../registry/ToolBundleRegistryClient.java | 5 +- .../biz/mcp/toolclient/HttpToolClient.java | 7 + .../mcp/toolclient/LocalFileToolClient.java | 130 ++++++++++++++++++ src/main/resources/application-dev.yml | 6 + src/main/resources/application-local.yml | 2 + src/main/resources/application.yml | 9 ++ .../PortalToolRegistryClientTest.java | 9 +- .../toolclient/LocalFileToolClientTest.java | 83 +++++++++++ 17 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 config/local-tool-responses-sample-v1.json create mode 100644 config/manifests/was-cus-manifest-sample-v1.json create mode 100644 config/manifests/was-pro-manifest-sample-v1.json create mode 100644 config/manifests/was-sal-manifest-sample-v1.json create mode 100644 config/manifests/was-sys-manifest-sample-v1.json create mode 100644 src/main/java/io/shinhanlife/dat/biz/mcp/config/LocalFixtureProperties.java create mode 100644 src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClient.java create mode 100644 src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java diff --git a/config/local-tool-responses-sample-v1.json b/config/local-tool-responses-sample-v1.json new file mode 100644 index 0000000..67e92d6 --- /dev/null +++ b/config/local-tool-responses-sample-v1.json @@ -0,0 +1,28 @@ +{ + "routes": { + "cus": { + "tools": { + "crm_activity_status": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "CUST001", "status": "ACTIVE", "lastActivityDate": "2026-09-08" } }, + "crm_customer_detail": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "CUST001", "customerName": "테스트 고객", "grade": "VIP", "status": "정상" } } + } + }, + "sal": { + "tools": { + "cmm_claim_search": { "httpStatus": 200, "body": { "source": "local-fixture", "claimNo": "CLM202609080001", "contractNo": "10023456789", "claimStatus": "심사중" } }, + "cmm_memo_retriever": { "httpStatus": 200, "body": { "source": "local-fixture", "memoStatus": "OPEN", "totalCount": 1, "items": [{ "memoId": "MEMO001", "title": "테스트 의뢰서" }] } } + } + }, + "pro": { + "tools": { + "pro_individual_inquiry": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "12345", "name": "김테스트", "phoneNumber": "010-0000-0000", "status": "정상" } }, + "pro_recruitment_data": { "httpStatus": 200, "body": { "source": "local-fixture", "companyCode": "005930", "year": "2026", "quarter": "3", "commissionRate": 12.5 } } + } + }, + "sys": { + "tools": { + "iam_access_policy": { "httpStatus": 200, "body": { "source": "local-fixture", "environment": "dev", "policyName": "DEV_DEFAULT", "status": "ACTIVE" } }, + "iam_access_token_status": { "httpStatus": 200, "body": { "source": "local-fixture", "environment": "dev", "tokenStatus": "VALID", "expiresInSeconds": 3600 } } + } + } + } +} diff --git a/config/manifests/was-cus-manifest-sample-v1.json b/config/manifests/was-cus-manifest-sample-v1.json new file mode 100644 index 0000000..aaaeb4f --- /dev/null +++ b/config/manifests/was-cus-manifest-sample-v1.json @@ -0,0 +1,38 @@ +{ + "bundleId": "was-cus", + "revision": "local-cus-manifest-v1", + "tools": [ + { + "name": "crm_activity_status", + "endpoint": "/mcp/crm_activity_status", + "title": "고객 활동 현황 조회", + "description": "고객 활동 현황을 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "customerId": { "type": "string", "description": "고객 식별자" }, + "startDate": { "type": "string", "description": "조회 시작일(YYYY-MM-DD)" }, + "endDate": { "type": "string", "description": "조회 종료일(YYYY-MM-DD)" } + }, + "required": ["customerId"], + "additionalProperties": false + }, + "annotations": { "title": "고객 활동 현황 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + }, + { + "name": "crm_customer_detail", + "endpoint": "/mcp/crm_customer_detail", + "title": "고객 상세 정보 조회", + "description": "고객 상세 정보를 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { "customerId": { "type": "string", "description": "고객 식별자" } }, + "required": ["customerId"], + "additionalProperties": false + }, + "annotations": { "title": "고객 상세 정보 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + } + ] +} diff --git a/config/manifests/was-pro-manifest-sample-v1.json b/config/manifests/was-pro-manifest-sample-v1.json new file mode 100644 index 0000000..74facac --- /dev/null +++ b/config/manifests/was-pro-manifest-sample-v1.json @@ -0,0 +1,40 @@ +{ + "bundleId": "was-pro", + "revision": "local-pro-manifest-v1", + "tools": [ + { + "name": "pro_individual_inquiry", + "endpoint": "/mcp/pro_individual_inquiry", + "title": "개인고객 상세조회", + "description": "개인고객 상세 정보를 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "customerId": { "type": "string", "pattern": "^[A-Za-z0-9]{1,20}$", "description": "고객 식별자" }, + "name": { "type": "string", "description": "고객명" } + }, + "required": ["customerId"], + "additionalProperties": false + }, + "annotations": { "title": "개인고객 상세조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + }, + { + "name": "pro_recruitment_data", + "endpoint": "/mcp/pro_recruitment_data", + "title": "모집수수료 공시 조회", + "description": "모집수수료 공시 정보를 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "companyCode": { "type": "string", "pattern": "^[0-9]{6}$", "description": "회사 코드" }, + "year": { "type": "string", "pattern": "^[0-9]{4}$", "description": "조회 연도" } + }, + "required": ["companyCode", "year"], + "additionalProperties": false + }, + "annotations": { "title": "모집수수료 공시 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + } + ] +} diff --git a/config/manifests/was-sal-manifest-sample-v1.json b/config/manifests/was-sal-manifest-sample-v1.json new file mode 100644 index 0000000..9c8bbb5 --- /dev/null +++ b/config/manifests/was-sal-manifest-sample-v1.json @@ -0,0 +1,40 @@ +{ + "bundleId": "was-sal", + "revision": "local-sal-manifest-v1", + "tools": [ + { + "name": "cmm_claim_search", + "endpoint": "/mcp/cmm_claim_search", + "title": "보험금 청구 상태 조회", + "description": "청구번호로 보험금 청구 상태를 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "claimNo": { "type": "string", "description": "청구번호" }, + "contractNo": { "type": "string", "description": "계약번호" } + }, + "required": ["claimNo"], + "additionalProperties": false + }, + "annotations": { "title": "보험금 청구 상태 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + }, + { + "name": "cmm_memo_retriever", + "endpoint": "/mcp/cmm_memo_retriever", + "title": "의뢰서 목록 조회", + "description": "의뢰서 목록을 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { + "memoStatus": { "type": "string", "description": "의뢰서 상태" }, + "searchKeyword": { "type": "string", "description": "검색어" } + }, + "required": ["memoStatus", "searchKeyword"], + "additionalProperties": false + }, + "annotations": { "title": "의뢰서 목록 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + } + ] +} diff --git a/config/manifests/was-sys-manifest-sample-v1.json b/config/manifests/was-sys-manifest-sample-v1.json new file mode 100644 index 0000000..fd91d4a --- /dev/null +++ b/config/manifests/was-sys-manifest-sample-v1.json @@ -0,0 +1,34 @@ +{ + "bundleId": "was-sys", + "revision": "local-sys-manifest-v1", + "tools": [ + { + "name": "iam_access_policy", + "endpoint": "/mcp/iam_access_policy", + "title": "접근 정책 조회", + "description": "환경별 접근 정책을 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { "environment": { "type": "string", "description": "조회할 환경명" } }, + "required": ["environment"], + "additionalProperties": false + }, + "annotations": { "title": "접근 정책 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + }, + { + "name": "iam_access_token_status", + "endpoint": "/mcp/iam_access_token_status", + "title": "접근 토큰 상태 조회", + "description": "환경별 접근 토큰 상태를 가짜 데이터로 조회합니다.", + "inputSchema": { + "type": "object", + "properties": { "environment": { "type": "string", "description": "조회할 환경명" } }, + "required": ["environment"], + "additionalProperties": false + }, + "annotations": { "title": "접근 토큰 상태 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, + "_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true } + } + ] +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/LocalFixtureProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/LocalFixtureProperties.java new file mode 100644 index 0000000..d85bc20 --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/LocalFixtureProperties.java @@ -0,0 +1,49 @@ +package io.shinhanlife.dat.biz.mcp.config; + +import java.util.Map; +import java.util.Optional; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @package io.shinhanlife.dat.biz.mcp.config + * @className LocalFixtureProperties + * @description 실제 Tool Server가 준비되지 않은 local·dev 환경에서 사용할 임시 manifest와 Tool 응답 파일 위치를 관리하는 설정입니다. + * @author j.h.w + * @create 2026.09.08 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.08   j.h.w         최초생성
+ *
+ * 
+ */ +@ConfigurationProperties(prefix = "mcp.local-fixtures") +public record LocalFixtureProperties( + boolean enabled, + Map manifestFiles, + String toolResponseFile) { + + /** + * 설정 map을 불변 값으로 보정하고 응답 파일 위치가 생략된 경우 빈 문자열로 유지합니다. + */ + public LocalFixtureProperties { + manifestFiles = manifestFiles == null ? Map.of() : Map.copyOf(manifestFiles); + toolResponseFile = toolResponseFile == null ? "" : toolResponseFile.trim(); + } + + /** + * Portal의 serviceKey에 대응하는 임시 Tool manifest 파일 위치를 반환합니다. + * + * @param serviceKey Portal이 제공한 Tool Service 식별자 + * @return 설정된 manifest 파일 위치 + */ + public Optional manifestFile(String serviceKey) { + if (!enabled || serviceKey == null) { + return Optional.empty(); + } + String location = manifestFiles.get(serviceKey); + return location == null || location.isBlank() ? Optional.empty() : Optional.of(location.trim()); + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java index 06a864e..9f0350a 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -9,7 +9,7 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import java.io.IOException; import java.util.ArrayList; import java.util.List; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; @@ -33,7 +33,8 @@ import org.springframework.stereotype.Component; * */ @Profile("local") -@ConditionalOnExpression("!${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") +@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "false", matchIfMissing = true) +@ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "false", matchIfMissing = true) public class LocalFileToolRegistryClient implements ToolRegistryClient { private final ResourceLoader resourceLoader; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java index 759cfde..4ee8cde 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java @@ -2,6 +2,7 @@ package io.shinhanlife.dat.biz.mcp.registry; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; @@ -40,6 +41,7 @@ import org.springframework.web.client.RestClient; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.11 j.h.w 최초생성 + * 2026.09.08 j.h.w local·dev serviceKey별 임시 manifest 원천 연결 * * */ @@ -55,6 +57,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { private final Optional redisPortalRegistryCache; private final ObjectMapper objectMapper; private final ResourceLoader resourceLoader; + private final LocalFixtureProperties localFixtureProperties; private final java.util.concurrent.atomic.AtomicReference lastPortalRevision = new java.util.concurrent.atomic.AtomicReference<>(); private final java.util.concurrent.ConcurrentMap> bundlesByRoute = @@ -69,6 +72,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { * @param redisPortalRegistryCache 협력 객체입니다. * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. * @param resourceLoader 처리할 값입니다. + * @param localFixtureProperties local·dev 임시 manifest 설정입니다. */ public PortalToolRegistryClient( @Qualifier("manifestRestClient") RestClient restClient, @@ -76,13 +80,15 @@ public class PortalToolRegistryClient implements ToolRegistryClient { ToolBundleDiscovery discovery, Optional redisPortalRegistryCache, ObjectMapper objectMapper, - ResourceLoader resourceLoader) { + ResourceLoader resourceLoader, + LocalFixtureProperties localFixtureProperties) { this.restClient = restClient; this.properties = properties; this.discovery = discovery; this.redisPortalRegistryCache = redisPortalRegistryCache; this.objectMapper = objectMapper; this.resourceLoader = resourceLoader; + this.localFixtureProperties = localFixtureProperties; } /** @@ -465,11 +471,14 @@ public class PortalToolRegistryClient implements ToolRegistryClient { */ private Optional toBundle(String routeKey, JsonNode service) { try { + String serviceKey = required(service, "serviceKey"); String serviceDomain = trimTrailingSlash(required(service, "serviceDomain")); String manifestPath = normalizePath(required(service, "manifestPath")); + String manifestUrl = localFixtureProperties.manifestFile(serviceKey) + .orElse(serviceDomain + manifestPath); return Optional.of(new Bundle( - required(service, "serviceKey"), - serviceDomain + manifestPath, + serviceKey, + manifestUrl, serviceDomain, service.path("namePrefix").asText(""), true, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java index fdd57f1..61da1d5 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java @@ -24,7 +24,6 @@ import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; @@ -43,11 +42,11 @@ import org.springframework.web.client.RestClient; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.08 j.h.w local·dev resource manifest 직접 조회 지원 * * */ @Component -@ConditionalOnExpression("${mcp.discovery.enabled:false} || ${mcp.portal.enabled:false}") public class ToolBundleDiscovery { private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class); @@ -258,6 +257,9 @@ public class ToolBundleDiscovery { * @return 처리된 문자열 값을 반환합니다. */ private String fetchManifestBody(Bundle bundle) { + if (isResourceLocation(bundle.manifestUrl())) { + return fetchResourceManifestBody(bundle.manifestUrl()); + } int maxBytes = properties.discovery().maxManifestBytes(); return restClient .get() @@ -280,6 +282,26 @@ public class ToolBundleDiscovery { }); } + /** + * local·dev fixture가 지정한 file 또는 classpath manifest를 HTTP 응답과 같은 크기 제한으로 읽습니다. + * + * @param location Spring resource 형식의 manifest 파일 위치 + * @return UTF-8 manifest JSON 문자열 + */ + private String fetchResourceManifestBody(String location) { + return fetchFallbackManifestBody(location); + } + + /** + * manifest 위치가 외부 HTTP 주소가 아닌 local Spring resource인지 확인합니다. + * + * @param location manifest 조회 위치 + * @return file 또는 classpath 위치이면 {@code true} + */ + private boolean isResourceLocation(String location) { + return location.startsWith("file:") || location.startsWith("classpath:"); + } + /** * 한 번의 조회에서 검증을 통과한 매니페스트 내용입니다. {@code revision}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다. */ diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java index cf0d507..cdcb0f5 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java @@ -11,7 +11,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; /** @@ -30,7 +30,8 @@ import org.springframework.stereotype.Component; * */ @Component -@ConditionalOnExpression("${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") +@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +@ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "false", matchIfMissing = true) public class ToolBundleRegistryClient implements ToolRegistryClient { private final ToolBundleDiscovery discovery; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java index 43de0b9..41d89a0 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java @@ -20,6 +20,7 @@ import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; @@ -40,10 +41,16 @@ import org.springframework.web.client.RestClientException; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.08 j.h.w 임시 local fixture 활성 시 실제 HTTP 호출 비활성화 * * */ @Component +@ConditionalOnProperty( + prefix = "mcp.local-fixtures", + name = "enabled", + havingValue = "false", + matchIfMissing = true) public class HttpToolClient implements ToolClient { private static final Logger log = LoggerFactory.getLogger(HttpToolClient.class); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClient.java new file mode 100644 index 0000000..623a853 --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClient.java @@ -0,0 +1,130 @@ +package io.shinhanlife.dat.biz.mcp.toolclient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; +import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException; +import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest; +import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +/** + * @package io.shinhanlife.dat.biz.mcp.toolclient + * @className LocalFileToolClient + * @description 실제 Tool Server가 없는 local·dev 검증에서 route와 Tool 이름에 대응하는 임시 JSON 응답을 반환하는 outbound adapter입니다. + * @author j.h.w + * @create 2026.09.08 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.08   j.h.w         최초생성
+ *
+ * 
+ */ +@Component +@Profile({"local", "dev"}) +@ConditionalOnProperty(prefix = "mcp.local-fixtures", name = "enabled", havingValue = "true") +public class LocalFileToolClient implements ToolClient { + + private final ObjectMapper objectMapper; + private final ResourceLoader resourceLoader; + private final LocalFixtureProperties properties; + + /** + * 임시 응답 JSON을 읽는 데 필요한 mapper, resource loader와 파일 설정을 주입받습니다. + * + * @param objectMapper JSON mapper + * @param resourceLoader file·classpath resource loader + * @param properties local fixture 설정 + */ + public LocalFileToolClient( + ObjectMapper objectMapper, + ResourceLoader resourceLoader, + LocalFixtureProperties properties) { + this.objectMapper = objectMapper; + this.resourceLoader = resourceLoader; + this.properties = properties; + } + + /** + * 현재 요청의 routeKey와 Tool 이름으로 가짜 응답을 찾아 반환합니다. 파일을 호출마다 읽으므로 테스트 응답 수정은 서버 재기동 없이 다음 호출부터 반영됩니다. + * + * @param request 실행할 Tool 요청 + * @param context routeKey를 포함한 MCP 요청 context + * @return 설정된 가짜 Tool 응답 + */ + @Override + public ToolResponse execute(ToolRequest request, McpRequestContext context) { + String location = properties.toolResponseFile(); + if (location.isBlank()) { + throw executionFailure(request.toolName(), "local Tool response file is not configured", null, null); + } + Resource resource = resourceLoader.getResource(location); + try (InputStream input = resource.getInputStream()) { + JsonNode configured = objectMapper.readTree(input) + .path("routes") + .path(context.routeKey()) + .path("tools") + .path(request.toolName()); + if (configured.isMissingNode()) { + throw executionFailure(request.toolName(), "local Tool response is not configured", null, null); + } + int status = configured.path("httpStatus").asInt(200); + JsonNode body = configured.get("body"); + if (body == null || body.isNull()) { + throw executionFailure(request.toolName(), "local Tool response body is not configured", null, status); + } + if (status < 200 || status >= 300) { + throw statusFailure(request.toolName(), status); + } + return new ToolResponse(status, body.deepCopy()); + } catch (ToolClientException exception) { + throw exception; + } catch (IOException | RuntimeException exception) { + throw executionFailure(request.toolName(), "unable to read local Tool response", exception, null); + } + } + + /** + * 가짜 HTTP 상태를 실제 Tool HTTP client와 같은 실패 종류로 변환합니다. + * + * @param toolName Tool 이름 + * @param status 가짜 HTTP 상태 + * @return Tool client 실패 + */ + private ToolClientException statusFailure(String toolName, int status) { + ToolClientException.Kind kind = switch (status) { + case 401 -> ToolClientException.Kind.UNAUTHORIZED; + case 403 -> ToolClientException.Kind.FORBIDDEN; + default -> ToolClientException.Kind.EXECUTION; + }; + return new ToolClientException(kind, "Tool returned HTTP " + status + ": " + toolName, null, status); + } + + /** + * 임시 파일 누락이나 JSON 오류를 기존 Tool 실행 실패 계약으로 변환합니다. + * + * @param toolName Tool 이름 + * @param message 외부에 노출 가능한 오류 설명 + * @param cause 원인 예외 + * @param status 가짜 HTTP 상태 또는 {@code null} + * @return Tool client 실행 실패 + */ + private ToolClientException executionFailure( + String toolName, String message, Throwable cause, Integer status) { + return new ToolClientException( + ToolClientException.Kind.EXECUTION, + message + ": " + toolName, + cause, + status); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e69de29..658fa3c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +mcp: + portal: + enabled: true + registry-url: ${MCP_PORTAL_REGISTRY_URL:file:./config/local-toolserver-info-sample-v1.json} + local-fixtures: + enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a99a3f4..184734a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -7,6 +7,8 @@ mcp: enabled: true registry-url: file:./config/local-toolserver-info-sample-v1.json refresh-ttl-seconds: 15 + local-fixtures: + enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false} bundles: [] redis: enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 86bb7fa..b5546c8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -101,6 +101,15 @@ mcp: agent-routing-hints: enabled: ${MCP_AGENT_ROUTING_HINTS_ENABLED:true} manifest-path: ${MCP_AGENT_ROUTING_HINTS_MANIFEST_PATH:/tool-service-manifest} + # Temporary local/dev fixtures. Keep disabled by default and remove after real Tool Servers are available. + local-fixtures: + enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false} + manifest-files: + was-cus: file:./config/manifests/was-cus-manifest-sample-v1.json + was-sal: file:./config/manifests/was-sal-manifest-sample-v1.json + was-pro: file:./config/manifests/was-pro-manifest-sample-v1.json + was-sys: file:./config/manifests/was-sys-manifest-sample-v1.json + tool-response-file: file:./config/local-tool-responses-sample-v1.json # Declared per deployment. baseEndpoint is the execution address and is owned by this file only: # nothing a Tool Service returns can change where MCP sends the call. bundles: [] diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java index 85ee67a..5b10fe5 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; +import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -265,7 +266,13 @@ class PortalToolRegistryClientTest { List.of()); ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties); return new PortalToolRegistryClient( - restClient, mcpProperties, discovery, redisPortalRegistryCache, OBJECT_MAPPER, new DefaultResourceLoader()); + restClient, + mcpProperties, + discovery, + redisPortalRegistryCache, + OBJECT_MAPPER, + new DefaultResourceLoader(), + new LocalFixtureProperties(false, java.util.Map.of(), "")); } private MockResponse portalRegistry(String revision) { diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java new file mode 100644 index 0000000..053f08d --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/LocalFileToolClientTest.java @@ -0,0 +1,83 @@ +package io.shinhanlife.dat.biz.mcp.toolclient; + +import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER; +import static io.shinhanlife.dat.biz.mcp.TestFixtures.context; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; +import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; +import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException; +import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; + +class LocalFileToolClientTest { + + @Test + void returnsConfiguredResponseByRouteAndToolName() throws Exception { + LocalFileToolClient client = client(); + + var response = client.execute( + new ToolRequest( + "crm_customer_detail", + "1.0.0", + "https://unused.example/mcp/crm_customer_detail", + OBJECT_MAPPER.readTree("{\"customerId\":\"CUST001\"}"), + 5_000), + routeContext("cus")); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.data().path("customerId").asText()).isEqualTo("CUST001"); + assertThat(response.data().path("source").asText()).isEqualTo("local-fixture"); + } + + @Test + void rejectsToolWithoutConfiguredResponse() throws Exception { + LocalFileToolClient client = client(); + + assertThatThrownBy(() -> client.execute( + new ToolRequest( + "missing_tool", + "1.0.0", + "https://unused.example/mcp/missing_tool", + OBJECT_MAPPER.readTree("{}"), + 5_000), + routeContext("cus"))) + .isInstanceOf(ToolClientException.class) + .hasMessageContaining("local Tool response is not configured"); + } + + private LocalFileToolClient client() { + return new LocalFileToolClient( + OBJECT_MAPPER, + new DefaultResourceLoader(), + new LocalFixtureProperties( + true, + Map.of(), + "file:./config/local-tool-responses-sample-v1.json")); + } + + private McpRequestContext routeContext(String routeKey) { + McpRequestContext base = context(); + return new McpRequestContext( + routeKey, + base.guid(), + base.prafNo(), + base.requestId(), + base.requestTime(), + base.virtualPrafNo(), + base.appCode(), + base.projectCode(), + base.userIp(), + base.callerIp(), + base.callerHost(), + base.channel(), + base.agentId(), + base.mcpSessionId(), + base.authorization(), + Instant.now().plusSeconds(10)); + } +}