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
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 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)); + } +}