local-fixtures 추가 가상 tool server json파일로 대체
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m33s
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m33s
This commit is contained in:
28
config/local-tool-responses-sample-v1.json
Normal file
28
config/local-tool-responses-sample-v1.json
Normal file
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
config/manifests/was-cus-manifest-sample-v1.json
Normal file
38
config/manifests/was-cus-manifest-sample-v1.json
Normal file
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
40
config/manifests/was-pro-manifest-sample-v1.json
Normal file
40
config/manifests/was-pro-manifest-sample-v1.json
Normal file
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
40
config/manifests/was-sal-manifest-sample-v1.json
Normal file
40
config/manifests/was-sal-manifest-sample-v1.json
Normal file
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
34
config/manifests/was-sys-manifest-sample-v1.json
Normal file
34
config/manifests/was-sys-manifest-sample-v1.json
Normal file
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* <pre>
|
||||
* ============ 개정이력 ============
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- ---------- ----------------
|
||||
* 2026.09.08 j.h.w 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "mcp.local-fixtures")
|
||||
public record LocalFixtureProperties(
|
||||
boolean enabled,
|
||||
Map<String, String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
* </pre>
|
||||
*/
|
||||
@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;
|
||||
|
||||
@@ -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 원천 연결
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -55,6 +57,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
||||
private final Optional<RedisPortalRegistryCache> redisPortalRegistryCache;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final LocalFixtureProperties localFixtureProperties;
|
||||
private final java.util.concurrent.atomic.AtomicReference<String> lastPortalRevision =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
private final java.util.concurrent.ConcurrentMap<String, List<Bundle>> 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> 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<Bundle> 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,
|
||||
|
||||
@@ -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 직접 조회 지원
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@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}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
* </pre>
|
||||
*/
|
||||
@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;
|
||||
|
||||
@@ -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 호출 비활성화
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@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);
|
||||
|
||||
@@ -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
|
||||
*
|
||||
* <pre>
|
||||
* ============ 개정이력 ============
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- ---------- ----------------
|
||||
* 2026.09.08 j.h.w 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user