local-fixtures 추가 가상 tool server json파일로 대체
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m33s

This commit is contained in:
2026-09-08 13:56:13 +09:00
parent 6653030f03
commit 2d730ea9c1
17 changed files with 516 additions and 10 deletions

View 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 } }
}
}
}
}

View 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 }
}
]
}

View 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 }
}
]
}

View 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 }
}
]
}

View 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 }
}
]
}

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다.
*/

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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}

View File

@@ -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

View File

@@ -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: []

View File

@@ -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) {

View File

@@ -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));
}
}