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.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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.context.annotation.Profile;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.ResourceLoader;
|
import org.springframework.core.io.ResourceLoader;
|
||||||
@@ -33,7 +33,8 @@ import org.springframework.stereotype.Component;
|
|||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Profile("local")
|
@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 {
|
public class LocalFileToolRegistryClient implements ToolRegistryClient {
|
||||||
|
|
||||||
private final ResourceLoader resourceLoader;
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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;
|
||||||
import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle;
|
import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle;
|
||||||
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
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.08.11 j.h.w 최초생성
|
||||||
|
* 2026.09.08 j.h.w local·dev serviceKey별 임시 manifest 원천 연결
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +57,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
|||||||
private final Optional<RedisPortalRegistryCache> redisPortalRegistryCache;
|
private final Optional<RedisPortalRegistryCache> redisPortalRegistryCache;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ResourceLoader resourceLoader;
|
private final ResourceLoader resourceLoader;
|
||||||
|
private final LocalFixtureProperties localFixtureProperties;
|
||||||
private final java.util.concurrent.atomic.AtomicReference<String> lastPortalRevision =
|
private final java.util.concurrent.atomic.AtomicReference<String> lastPortalRevision =
|
||||||
new java.util.concurrent.atomic.AtomicReference<>();
|
new java.util.concurrent.atomic.AtomicReference<>();
|
||||||
private final java.util.concurrent.ConcurrentMap<String, List<Bundle>> bundlesByRoute =
|
private final java.util.concurrent.ConcurrentMap<String, List<Bundle>> bundlesByRoute =
|
||||||
@@ -69,6 +72,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
|||||||
* @param redisPortalRegistryCache 협력 객체입니다.
|
* @param redisPortalRegistryCache 협력 객체입니다.
|
||||||
* @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다.
|
* @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다.
|
||||||
* @param resourceLoader 처리할 값입니다.
|
* @param resourceLoader 처리할 값입니다.
|
||||||
|
* @param localFixtureProperties local·dev 임시 manifest 설정입니다.
|
||||||
*/
|
*/
|
||||||
public PortalToolRegistryClient(
|
public PortalToolRegistryClient(
|
||||||
@Qualifier("manifestRestClient") RestClient restClient,
|
@Qualifier("manifestRestClient") RestClient restClient,
|
||||||
@@ -76,13 +80,15 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
|||||||
ToolBundleDiscovery discovery,
|
ToolBundleDiscovery discovery,
|
||||||
Optional<RedisPortalRegistryCache> redisPortalRegistryCache,
|
Optional<RedisPortalRegistryCache> redisPortalRegistryCache,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ResourceLoader resourceLoader) {
|
ResourceLoader resourceLoader,
|
||||||
|
LocalFixtureProperties localFixtureProperties) {
|
||||||
this.restClient = restClient;
|
this.restClient = restClient;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.discovery = discovery;
|
this.discovery = discovery;
|
||||||
this.redisPortalRegistryCache = redisPortalRegistryCache;
|
this.redisPortalRegistryCache = redisPortalRegistryCache;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.resourceLoader = resourceLoader;
|
this.resourceLoader = resourceLoader;
|
||||||
|
this.localFixtureProperties = localFixtureProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,11 +471,14 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
|
|||||||
*/
|
*/
|
||||||
private Optional<Bundle> toBundle(String routeKey, JsonNode service) {
|
private Optional<Bundle> toBundle(String routeKey, JsonNode service) {
|
||||||
try {
|
try {
|
||||||
|
String serviceKey = required(service, "serviceKey");
|
||||||
String serviceDomain = trimTrailingSlash(required(service, "serviceDomain"));
|
String serviceDomain = trimTrailingSlash(required(service, "serviceDomain"));
|
||||||
String manifestPath = normalizePath(required(service, "manifestPath"));
|
String manifestPath = normalizePath(required(service, "manifestPath"));
|
||||||
|
String manifestUrl = localFixtureProperties.manifestFile(serviceKey)
|
||||||
|
.orElse(serviceDomain + manifestPath);
|
||||||
return Optional.of(new Bundle(
|
return Optional.of(new Bundle(
|
||||||
required(service, "serviceKey"),
|
serviceKey,
|
||||||
serviceDomain + manifestPath,
|
manifestUrl,
|
||||||
serviceDomain,
|
serviceDomain,
|
||||||
service.path("namePrefix").asText(""),
|
service.path("namePrefix").asText(""),
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import java.util.regex.Pattern;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
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.DefaultResourceLoader;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.ResourceLoader;
|
import org.springframework.core.io.ResourceLoader;
|
||||||
@@ -43,11 +42,11 @@ import org.springframework.web.client.RestClient;
|
|||||||
* 수정일 수정자 수정내용
|
* 수정일 수정자 수정내용
|
||||||
* ---------- ---------- ----------------
|
* ---------- ---------- ----------------
|
||||||
* 2026.08.06 j.h.w 최초생성
|
* 2026.08.06 j.h.w 최초생성
|
||||||
|
* 2026.09.08 j.h.w local·dev resource manifest 직접 조회 지원
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnExpression("${mcp.discovery.enabled:false} || ${mcp.portal.enabled:false}")
|
|
||||||
public class ToolBundleDiscovery {
|
public class ToolBundleDiscovery {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class);
|
private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class);
|
||||||
@@ -258,6 +257,9 @@ public class ToolBundleDiscovery {
|
|||||||
* @return 처리된 문자열 값을 반환합니다.
|
* @return 처리된 문자열 값을 반환합니다.
|
||||||
*/
|
*/
|
||||||
private String fetchManifestBody(Bundle bundle) {
|
private String fetchManifestBody(Bundle bundle) {
|
||||||
|
if (isResourceLocation(bundle.manifestUrl())) {
|
||||||
|
return fetchResourceManifestBody(bundle.manifestUrl());
|
||||||
|
}
|
||||||
int maxBytes = properties.discovery().maxManifestBytes();
|
int maxBytes = properties.discovery().maxManifestBytes();
|
||||||
return restClient
|
return restClient
|
||||||
.get()
|
.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}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다.
|
* 한 번의 조회에서 검증을 통과한 매니페스트 내용입니다. {@code revision}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,8 @@ import org.springframework.stereotype.Component;
|
|||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@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 {
|
public class ToolBundleRegistryClient implements ToolRegistryClient {
|
||||||
|
|
||||||
private final ToolBundleDiscovery discovery;
|
private final ToolBundleDiscovery discovery;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import java.util.UUID;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
@@ -40,10 +41,16 @@ import org.springframework.web.client.RestClientException;
|
|||||||
* 수정일 수정자 수정내용
|
* 수정일 수정자 수정내용
|
||||||
* ---------- ---------- ----------------
|
* ---------- ---------- ----------------
|
||||||
* 2026.08.06 j.h.w 최초생성
|
* 2026.08.06 j.h.w 최초생성
|
||||||
|
* 2026.09.08 j.h.w 임시 local fixture 활성 시 실제 HTTP 호출 비활성화
|
||||||
*
|
*
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@ConditionalOnProperty(
|
||||||
|
prefix = "mcp.local-fixtures",
|
||||||
|
name = "enabled",
|
||||||
|
havingValue = "false",
|
||||||
|
matchIfMissing = true)
|
||||||
public class HttpToolClient implements ToolClient {
|
public class HttpToolClient implements ToolClient {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(HttpToolClient.class);
|
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
|
enabled: true
|
||||||
registry-url: file:./config/local-toolserver-info-sample-v1.json
|
registry-url: file:./config/local-toolserver-info-sample-v1.json
|
||||||
refresh-ttl-seconds: 15
|
refresh-ttl-seconds: 15
|
||||||
|
local-fixtures:
|
||||||
|
enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false}
|
||||||
bundles: []
|
bundles: []
|
||||||
redis:
|
redis:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ mcp:
|
|||||||
agent-routing-hints:
|
agent-routing-hints:
|
||||||
enabled: ${MCP_AGENT_ROUTING_HINTS_ENABLED:true}
|
enabled: ${MCP_AGENT_ROUTING_HINTS_ENABLED:true}
|
||||||
manifest-path: ${MCP_AGENT_ROUTING_HINTS_MANIFEST_PATH:/tool-service-manifest}
|
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:
|
# 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.
|
# nothing a Tool Service returns can change where MCP sends the call.
|
||||||
bundles: []
|
bundles: []
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties;
|
||||||
import io.shinhanlife.dat.biz.mcp.config.McpProperties;
|
import io.shinhanlife.dat.biz.mcp.config.McpProperties;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -265,7 +266,13 @@ class PortalToolRegistryClientTest {
|
|||||||
List.of());
|
List.of());
|
||||||
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
|
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
|
||||||
return new PortalToolRegistryClient(
|
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) {
|
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