diff --git a/config/local-toolserver-info-sample-v1.json b/config/local-toolserver-info-sample-v1.json index 443108d..5f843fa 100644 --- a/config/local-toolserver-info-sample-v1.json +++ b/config/local-toolserver-info-sample-v1.json @@ -9,22 +9,6 @@ "displayName": "CUS Tool Server", "serviceDomain": "https://tool-cus.devjun.net", "manifestPath": "/tool-manifest", - "executeBasePath": "", - "namePrefix": "", - "toolEndpoints": { - "ins_insurance_processor": "/mcp/ins_insurance_processor", - "cmm_customer_tool": "/mcp/cmm_customer_tool", - "cmm_template_url": "/mcp/cmm_template_url", - "smp_exchange_inquiry": "/mcp/smp_exchange_inquiry", - "sol_request_detail": "/mcp/sol_request_detail", - "smp_weather_inquiry": "/mcp/smp_weather_inquiry", - "sol_request_list": "/mcp/sol_request_list", - "cmm_comcode_lookup": "/mcp/cmm_comcode_lookup", - "cmm_meta_table": "/mcp/cmm_meta_table", - "smp_quote_daily": "/mcp/smp_quote_daily", - "oth_onnba3011_call": "/mcp/oth_onnba3011_call", - "smp_team_list": "/mcp/smp_team_list" - }, "status": "ACTIVE" } ] @@ -37,9 +21,6 @@ "displayName": "SAL Tool Server", "serviceDomain": "https://tool-sal.devjun.net", "manifestPath": "/tool-manifest", - "executeBasePath": "", - "namePrefix": "", - "toolEndpoints": {}, "status": "ACTIVE" } ] @@ -52,9 +33,6 @@ "displayName": "PRO Tool Server", "serviceDomain": "https://tool-pro.devjun.net", "manifestPath": "/tool-manifest", - "executeBasePath": "", - "namePrefix": "", - "toolEndpoints": {}, "status": "ACTIVE" } ] @@ -67,12 +45,9 @@ "displayName": "SYS Tool Server", "serviceDomain": "https://tool-sys.devjun.net", "manifestPath": "/tool-manifest", - "executeBasePath": "", - "namePrefix": "", - "toolEndpoints": {}, "status": "ACTIVE" } ] } ] -} \ No newline at end of file +} diff --git a/docs/architecture.md b/docs/architecture.md index 239f514..c5751b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,8 +30,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T 6. `McpMethodHandlerRegistry`가 method를 명시적 handler에 연결한다. 7. `tools/list`는 `ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool`과 `ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다. 8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다. -9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 안전한 Tool name을 설정의 base endpoint 뒤에 붙여 `POST {baseEndpoint}/{toolName}` 요청과 metadata timeout을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다. -10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. endpoint는 local catalog의 `_meta.endpoint` 또는 운영 배포 설정의 `baseEndpoint`에서만 가져오므로 Agent Builder 입력이나 Tool Service 매니페스트로 outbound 대상이 바뀌지 않는다. +9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다. +10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다. 11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다. 12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다. 13. local과 운영 모두 같은 `name` lookup, endpoint/timeout, inputSchema validation 경로를 사용한다. @@ -198,9 +198,9 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. 로컬 검증에서는 `mcp.portal.registry-url`을 `file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다. -Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server endpoint 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 endpoint 목록을 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 endpoint 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 endpoint 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory endpoint snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 endpoint 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 endpoint 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다. +Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 Tool Server 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다. -노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 설정된 `base-endpoint`에 요청 name을 path segment로 붙여 `tools/call`을 POST한다. +노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta`와 `endpoint` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 top-level `endpoint` 또는 `_meta.endpoint`를 내부 실행 endpoint로 사용한다. 이 fixture는 연동 확인용이며 실제 고객·계약·수납·지급 데이터를 담지 않는다. diff --git a/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md index 78af2ec..a88a376 100644 --- a/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md +++ b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md @@ -45,7 +45,7 @@ ToolBundleRegistryClient.fetchTools() -> ToolBundleDiscovery.discoverAll() -> GET {manifestUrl} -> bundleId / tools[] / Tool 필수 필드 검증 - -> ToolMetadata 생성 (endpoint는 MCP 배포 설정의 baseEndpoint 사용) + -> ToolMetadata 생성 (endpoint는 Tool Service manifest의 top-level endpoint 또는 _meta.endpoint 사용) -> enabled=false Tool 제외 -> immutable List를 AtomicReference snapshot에 저장 ``` @@ -213,7 +213,7 @@ Content-Type: application/json 3. 모든 Tool에 고유한 표준 `name`, 비어 있지 않은 `description`, object 형태의 `inputSchema`, `_meta.version`을 넣는다. 4. Tool을 숨기려면 `_meta.enabled: false`를 쓰거나 정상 전체 목록에서 제거한다. 둘의 변경 반영 시점은 다음 refresh다. 5. 실행 주소·credential·개인정보·업무 payload를 매니페스트에 넣지 않는다. -6. Tool 자체 실행 endpoint는 별도로 `POST {baseEndpoint}/{toolName}`을 구현한다. manifest endpoint는 실행 endpoint가 아니다. +6. Tool 자체 실행 endpoint는 manifest의 top-level `endpoint` 또는 `_meta.endpoint`에 선언한다. 상대 경로는 Portal registry의 `serviceDomain` 뒤에 붙고, 절대 HTTP(S) URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용한다. ## 확인한 구현·테스트 diff --git a/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json index 0d9b501..0698c85 100644 --- a/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json +++ b/docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json @@ -27,6 +27,7 @@ }, "_meta": { "version": "1.2.0", + "endpoint": "/mcp/processing.contract.inquiry", "timeoutMillis": 3000, "enabled": true } @@ -61,6 +62,7 @@ }, "_meta": { "version": "1.0.1", + "endpoint": "/mcp/processing.payment.history", "timeoutMillis": 5000, "enabled": true } @@ -86,6 +88,7 @@ }, "_meta": { "version": "0.9.0", + "endpoint": "/mcp/processing.notice.send", "timeoutMillis": 10000, "enabled": false } diff --git a/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md index 00c719e..27728fe 100644 --- a/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md +++ b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md @@ -4,7 +4,7 @@ - 기준일: 2026-07-30 - 대체 대상: push 등록 방식(v0.1). 채택하지 않은 이유는 §2 - 조회 endpoint: `GET {manifestUrl}` — Tool Service가 제공 -- 실행 endpoint: `POST {baseEndpoint}/{toolName}` — 현재 구현 +- 실행 endpoint: Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint` — 현재 구현 ## 1. 계약 범위와 원칙 @@ -15,7 +15,7 @@ MCP Server는 자기 설정에 선언된 bundle의 매니페스트를 **주기 |---|---| | MCP가 가져온다 | Tool Service는 매니페스트를 제공만 한다. MCP에 등록 요청을 보내지 않는다 | | 조회 대상은 설정이 정한다 | 어떤 bundle이 이 MCP에 속하는지는 배포 시점 YAML로 확정된다 | -| **라우팅 주소는 설정이 소유한다** | 호출 대상 주소는 MCP 설정에서만 온다. 매니페스트가 바꿀 수 없다 | +| **Tool Server domain은 포털이, Tool endpoint는 Tool Service가 소유한다** | 포털은 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, 개별 Tool 실행 endpoint는 Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 온다 | | 매니페스트는 전체 상태 | 응답은 그 bundle의 Tool 전체 목록이다. 증분 없음 | | 조회 성공이 생존 신호 | 별도 heartbeat·TTL 장치가 없다 | | bundle 단위 조회 격리 | 한 bundle의 조회 실패가 다른 bundle의 조회를 중단시키지 않는다 | @@ -170,7 +170,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기 | `revision` | 아니오 | 매니페스트 버전. 변경 감지·로그·ETag에만 쓰인다 | | `tools` | 예 | 이 bundle이 노출하는 Tool 전체. 빈 배열은 "노출할 Tool 없음"이다 | -`baseEndpoint`는 **매니페스트에 넣지 않는다.** 넣어도 MCP는 무시한다(§1 세 번째 원칙). +Tool 실행 endpoint는 각 Tool의 top-level `endpoint` 또는 `_meta.endpoint`에 넣는다. 상대 경로를 쓰면 포털 registry의 `serviceDomain` 뒤에 붙고, 절대 URL을 쓰면 Tool Service manifest가 제공한 실행 주소 원천으로 그대로 사용한다. HTTP(S)가 아닌 scheme은 거부한다. ### `tools[]` 필드 @@ -299,7 +299,7 @@ management port(운영 기본 9090)에서 MCP가 알고 있는 bundle의 조회 이미 구현되어 있는 계약이다. Tool Service는 아래를 받을 수 있어야 한다. ```text -POST {baseEndpoint}/{toolName} +POST {endpoint} Content-Type: application/json guid, x-request-id, mcp-session-id, employee-no, virtual-employee-no Authorization: <설정에 따라 전달> diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java index f25df00..1b2c894 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java @@ -8,7 +8,6 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; import java.util.List; -import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -178,12 +177,7 @@ public record McpProperties( @NotBlank String baseEndpoint, @NotBlank String namePrefix, boolean enabled, - String fallbackManifestFile, - Map toolEndpoints) { - - public Bundle { - toolEndpoints = toolEndpoints == null ? Map.of() : Map.copyOf(toolEndpoints); - } + String fallbackManifestFile) { } /** diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java index 463a05a..857efa7 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java @@ -29,7 +29,8 @@ public class ToolRoutingService { } /** - * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. 절대 HTTP(S) endpoint와 Tool 이름을 검증하고 {@code POST {baseEndpoint}/{toolName}} 주소를 확정합니다. + * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. exact endpoint metadata는 그대로 사용하고, legacy base endpoint metadata만 Tool 이름을 + * path segment로 덧붙입니다. 확정된 endpoint가 절대 HTTP(S)가 아니면 JSON-RPC Tool 실행 오류로 변환합니다. */ public ToolRequest route(ToolCall call, ToolMetadata metadata) { String endpoint = metadata.endpoint(); diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java index 1a6edbd..251bd18 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java @@ -108,6 +108,19 @@ public class PortalToolRegistryClient implements ToolRegistryClient { return registerPortalRegistry(registryUrl, registry); } + /** + * Portal registry endpoint snapshot에 현재 route key가 존재하는지 request path에서 빠르게 확인합니다. + * 네트워크 조회를 새로 수행하지 않고 이미 적재된 {@code bundlesByRoute}만 읽으므로 잘못된 route가 controller와 Tool 실행 계층으로 들어가지 못하게 하는 1차 방어선입니다. + */ + @Override + public boolean isKnownRoute(String routeKey) { + try { + return bundlesByRoute.containsKey(normalizeRouteKey(routeKey)); + } catch (JsonRpcException exception) { + return false; + } + } + /** * Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다. * 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다. @@ -268,7 +281,8 @@ public class PortalToolRegistryClient implements ToolRegistryClient { /** * 포털의 active Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다. - * service domain, manifest path, 실행 base path를 안정적인 URL 조합으로 정규화하며 active 서비스가 없으면 갱신을 거부합니다. + * 포털 registry는 Tool Server의 domain과 manifest 위치만 제공하므로 개별 Tool endpoint 목록은 읽지 않습니다. Tool 실행 endpoint는 이후 + * Tool Server manifest의 {@code _meta.endpoint}에서 확정하며, active 서비스가 없으면 갱신을 거부합니다. */ private List toBundles(JsonNode services) { List bundles = new ArrayList<>(); @@ -278,21 +292,13 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } String serviceDomain = trimTrailingSlash(required(service, "serviceDomain")); String manifestPath = normalizePath(required(service, "manifestPath")); - String executeBasePath = normalizeOptionalPath(service.path("executeBasePath").asText("")); - Map toolEndpoints = new LinkedHashMap<>(); - JsonNode endpointNode = service.path("toolEndpoints"); - if (endpointNode.isObject()) { - endpointNode.fields().forEachRemaining(entry -> - toolEndpoints.put(entry.getKey(), normalizePath(entry.getValue().asText()))); - } bundles.add(new Bundle( required(service, "serviceKey"), serviceDomain + manifestPath, - trimTrailingSlash(serviceDomain + executeBasePath), + serviceDomain, service.path("namePrefix").asText(""), true, - null, - toolEndpoints)); + null)); } if (bundles.isEmpty()) { throw unavailable("Portal registry has no active Tool Service"); @@ -348,17 +354,6 @@ public class PortalToolRegistryClient implements ToolRegistryClient { return "/" + path.replaceAll("^/+", ""); } - /** - * 포털 응답의 선택 실행 base path를 domain 뒤에 붙일 수 있는 경로로 정규화합니다. - * 빈 값은 root 경로에 Tool name을 바로 붙이는 실행 계약을 의미합니다. - */ - private String normalizeOptionalPath(String path) { - if (path == null || path.isBlank() || "/".equals(path)) { - return ""; - } - return "/" + path.replaceAll("^/+", "").replaceAll("/+$", ""); - } - /** * service domain 또는 실행 base endpoint 끝의 중복 slash를 제거해 routing 결과를 안정화합니다. */ diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java index c92a721..60addc3 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java @@ -7,6 +7,7 @@ import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; @@ -245,8 +246,8 @@ public class ToolBundleDiscovery { } /** - * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, 실행 주소는 설정의 {@code baseEndpoint}에서만 가져옵니다. 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않으므로 Tool Service가 호출 대상을 바꿀 수 없습니다. 이름 - * 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. + * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, Tool 실행 경로는 Tool Service manifest의 {@code endpoint} 또는 {@code _meta.endpoint}에서 가져옵니다. 포털 registry가 제공한 + * {@code serviceDomain}은 상대 endpoint를 절대 URL로 바꾸는 기준으로만 사용합니다. 이름 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. */ private ToolMetadata toToolMetadata(Bundle bundle, JsonNode tool) { String name = tool.path("name").asText(null); @@ -270,12 +271,7 @@ public class ToolBundleDiscovery { if (version == null || version.isBlank()) { throw new IllegalStateException("Tool _meta.version is required"); } - String endpointPath = bundle.toolEndpoints().get(name); - boolean exactEndpoint = endpointPath != null && !endpointPath.isBlank(); - String endpoint = bundle.baseEndpoint().replaceAll("/+$", ""); - if (exactEndpoint) { - endpoint += "/" + endpointPath.replaceAll("^/+", ""); - } + String endpoint = resolveManifestEndpoint(bundle, tool, meta, name); return new ToolMetadata( name, version, @@ -285,7 +281,56 @@ public class ToolBundleDiscovery { clampTimeout(meta), meta.path("enabled").asBoolean(true), publicDefinition(tool), - exactEndpoint); + true); + } + + /** + * Tool Service manifest가 제공한 Tool 실행 endpoint를 실제 호출 URL로 확정합니다. 최상위 {@code endpoint}를 먼저 읽고 없으면 {@code _meta.endpoint}를 사용합니다. 상대 경로는 + * 포털 registry의 {@code serviceDomain} 뒤에 붙이고, 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용합니다. endpoint가 비어 있거나 HTTP(S)가 아니면 bundle + * 전체를 거부합니다. + */ + private String resolveManifestEndpoint(Bundle bundle, JsonNode tool, JsonNode meta, String toolName) { + String declared = tool.path("endpoint").asText(null); + if (declared == null || declared.isBlank()) { + declared = meta.path("endpoint").asText(null); + } + if (declared == null || declared.isBlank()) { + throw new IllegalStateException("Tool endpoint is required"); + } + String trimmed = declared.trim(); + if (trimmed.contains("\r") || trimmed.contains("\n")) { + throw new IllegalStateException("Tool endpoint must be a single-line URI"); + } + URI base = endpointUri(bundle.baseEndpoint(), "Portal serviceDomain", toolName); + if (!base.isAbsolute()) { + throw new IllegalStateException("Portal serviceDomain must be absolute HTTP(S)"); + } + URI endpoint = endpointUri(trimmed, "Tool endpoint", toolName); + URI resolved = endpoint.isAbsolute() ? endpoint : base.resolve("/" + trimmed.replaceAll("^/+", "")); + return resolved.toString(); + } + + /** + * endpoint 문자열을 URI로 해석하고, Tool 실행에 사용할 수 있는 HTTP(S) 주소 또는 상대 경로인지 확인합니다. 잘못된 URI는 discovery 실패로 격리되어 직전 정상 snapshot을 + * 유지하게 됩니다. + */ + private URI endpointUri(String endpoint, String fieldName, String toolName) { + try { + URI uri = URI.create(endpoint); + if (uri.isAbsolute() + && !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) { + throw new IllegalArgumentException(fieldName + " must use HTTP(S)"); + } + if (uri.isAbsolute() && (uri.getHost() == null || uri.getHost().isBlank())) { + throw new IllegalArgumentException(fieldName + " must include host"); + } + if (!uri.isAbsolute() && endpoint.startsWith("//")) { + throw new IllegalArgumentException(fieldName + " must not be protocol-relative"); + } + return uri; + } catch (RuntimeException exception) { + throw new IllegalStateException("Invalid " + fieldName + " for " + toolName, exception); + } } /** @@ -303,11 +348,12 @@ public class ToolBundleDiscovery { } /** - * 매니페스트의 공개 Tool 정의를 복사하고 내부 실행 정보인 {@code _meta}만 제거해 tools/list 노출본을 만듭니다. + * 매니페스트의 공개 Tool 정의를 복사하고 내부 실행 정보인 {@code _meta}와 {@code endpoint}를 제거해 tools/list 노출본을 만듭니다. */ private JsonNode publicDefinition(JsonNode tool) { ObjectNode definition = ((ObjectNode) tool).deepCopy(); definition.remove("_meta"); + definition.remove("endpoint"); return definition; } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java index 3bf1578..31b0fc0 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java @@ -35,6 +35,14 @@ public interface ToolRegistryClient { return false; } + /** + * 요청 경로에서 받은 route key가 현재 Registry 원천이 알고 있는 route인지 확인합니다. + * 기본 구현은 route 개념이 없는 정적 Registry 구현과의 호환을 위해 허용으로 처리하며, Portal 기반 구현은 메모리에 적재된 endpoint snapshot만 조회해야 합니다. + */ + default boolean isKnownRoute(String routeKey) { + return true; + } + /** * route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다. */ diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java index 53fff88..402ce33 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.transport.http.McpRouteKeyValidator; import java.util.LinkedHashMap; import java.util.List; @@ -23,7 +24,7 @@ import org.springframework.stereotype.Service; * Tool Registry metadata 議고쉶???⑥씪 吏꾩엯?먯씠硫??붿껌 寃쎈줈?€ 諛곌꼍 媛깆떊 寃쎈줈瑜?遺꾨━?섎뒗 ?쒕퉬?ㅼ엯?덈떎. {@code tools/list}?€ {@code tools/call}???붿껌 寃쎈줈??in-memory snapshot留??쎌쑝誘€濡?Redis ?μ븷??吏€?곗씠 ?묐떟?? * ?곹뼢??二쇱? ?딆뒿?덈떎. Redis??諛곌꼍 媛깆떊怨?warm start?먯꽌留??ъ슜?섎뒗 replica 媛?怨듭쑀 吏€?먯씠硫? ?먯쿇 議고쉶 ?깃났 寃곌낵留??€?ν빀?덈떎. 二쇱슂 ?섏〈?깆? ?먯쿇 port {@link ToolRegistryClient}?€ ?좏깮??Redis cache?낅땲?? */ @Service -public class ToolRegistryService { +public class ToolRegistryService implements McpRouteKeyValidator { private static final Logger log = LoggerFactory.getLogger(ToolRegistryService.class); @@ -198,6 +199,15 @@ public class ToolRegistryService { return registryClient.refreshSourceRegistry(); } + /** + * 요청 path에서 추출한 route key가 현재 Registry 원천에 등록된 route인지 확인합니다. + * 요청 처리 중 Redis나 원격 Portal을 호출하지 않고 Registry client가 보유한 메모리 상태만 조회해, 알 수 없는 route를 controller 진입 전에 거부할 수 있게 합니다. + */ + @Override + public boolean isKnownRoute(String routeKey) { + return registryClient.isKnownRoute(normalizeRouteKey(routeKey)); + } + /** * Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory?€ Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛€ 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲?? */ diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java index 5953c3c..bb71716 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java @@ -39,6 +39,7 @@ public class McpExchangeFilter extends OncePerRequestFilter { private final ObjectMapper objectMapper; private final McpProperties properties; private final McpProtocolVersionValidator protocolVersionValidator; + private final McpRouteKeyValidator routeKeyValidator; /** * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 별도 payload capture나 audit sink는 조립하지 않습니다. @@ -48,12 +49,14 @@ public class McpExchangeFilter extends OncePerRequestFilter { TraceLogger traceLogger, ObjectMapper objectMapper, McpProperties properties, - McpProtocolVersionValidator protocolVersionValidator) { + McpProtocolVersionValidator protocolVersionValidator, + McpRouteKeyValidator routeKeyValidator) { this.headerExtractor = headerExtractor; this.traceLogger = traceLogger; this.objectMapper = objectMapper; this.properties = properties; this.protocolVersionValidator = protocolVersionValidator; + this.routeKeyValidator = routeKeyValidator; } /** @@ -61,6 +64,9 @@ public class McpExchangeFilter extends OncePerRequestFilter { */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { + if (!"POST".equalsIgnoreCase(request.getMethod())) { + return true; + } String path = request.getRequestURI(); String contextPath = request.getContextPath(); if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { @@ -93,6 +99,7 @@ public class McpExchangeFilter extends OncePerRequestFilter { long startedNanos = System.nanoTime(); try { McpRequestContext context = headerExtractor.extract(request); + validateKnownRoute(context, request); McpRequestContextHolder.set(context); response.setHeader("guid", context.guid()); response.setHeader("x-request-id", context.requestId()); @@ -166,6 +173,33 @@ public class McpExchangeFilter extends OncePerRequestFilter { } } + /** + * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. + * 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. + */ + private void validateKnownRoute(McpRequestContext context, HttpServletRequest request) { + if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) { + return; + } + if (!routeKeyValidator.isKnownRoute(context.routeKey())) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not registered"); + } + } + + /** + * 설정된 endpoint path 자체가 {@code /mcp/core}처럼 route를 이미 포함한 고정 배포인지 확인합니다. + * 이 경우 route 선택이 동적 URL 입력에서 온 것이 아니므로 Registry 기반 동적 route 차단 대상에서 제외하고 기존 고정 endpoint 계약을 유지합니다. + */ + private boolean isFixedEndpointRequest(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + path = path.substring(contextPath.length()); + } + String basePath = properties.endpointPath(); + return path.equals(basePath) && basePath != null && basePath.startsWith("/mcp/"); + } + /** * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. * diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java new file mode 100644 index 0000000..0b50b1c --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java @@ -0,0 +1,14 @@ +package io.shinhanlife.dap.biz.mcp.transport.http; + +/** + * MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다. + * HTTP 요청을 직접 처리하지 않고 {@link McpExchangeFilter}가 controller 진입 전에 호출하며, 실제 판단 기준은 Registry 계층의 메모리 snapshot 구현이 제공합니다. + */ +public interface McpRouteKeyValidator { + + /** + * 주어진 route key가 현재 허용 가능한지 확인합니다. + * 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + */ + boolean isKnownRoute(String routeKey); +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java index c587abf..826b8a8 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java @@ -6,7 +6,6 @@ import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; import java.time.Instant; import java.util.List; -import java.util.Map; public final class TestFixtures { @@ -38,7 +37,7 @@ public final class TestFixtures { public static McpProperties.Bundle bundle( String id, String manifestUrl, String baseEndpoint, String namePrefix) { - return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null, Map.of()); + return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null); } public static McpRequestContext context() { diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java index f5415d8..5babe9b 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java @@ -5,7 +5,6 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; @@ -83,8 +82,7 @@ class McpBundleConfigurationTest { "http://tool/mcp", "disabled.", false, - null, - Map.of()); + null); McpProperties properties = properties(false, false, List.of(disabled)); assertThat(properties.isDiscoveryTargetDeclared()).isFalse(); diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java index 6ac24ef..fd817da 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java @@ -77,12 +77,12 @@ class ToolBundleContractExampleTest { .extracting(ToolMetadata::name) .containsExactly( "processing.contract.inquiry", "processing.payment.history", "processing.notice.send"); - // 실행 주소는 설정에서만 온다. 매니페스트에는 endpoint가 없고 있어도 무시한다. assertThat(tools) - .allSatisfy( - tool -> - assertThat(tool.endpoint()) - .isEqualTo("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp")); + .extracting(ToolMetadata::endpoint) + .containsExactly( + "http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.contract.inquiry", + "http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.payment.history", + "http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.notice.send"); // _meta는 tools/list 공개본에 노출하지 않는다. assertThat(tools) .allSatisfy(tool -> assertThat(tool.publicDefinition().has("_meta")).isFalse()); diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java index 71c29dc..2251d7f 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java @@ -99,6 +99,9 @@ class PortalToolRegistryClientTest { assertThat(snapshots.get("external")) .extracting(ToolMetadata::name) .containsExactly("external.weather"); + assertThat(snapshots.get("external")) + .extracting(ToolMetadata::endpoint) + .containsExactly(toolServer.url("/mcp/external.weather").toString()); verify(redis).loadRegistry(); assertThat(portal.getRequestCount()).isEqualTo(1); assertThat(toolServer.getRequestCount()).isEqualTo(1); @@ -116,6 +119,9 @@ class PortalToolRegistryClientTest { assertThat(snapshots.get("external")) .extracting(ToolMetadata::name) .containsExactly("external.weather"); + assertThat(snapshots.get("external")) + .extracting(ToolMetadata::endpoint) + .containsExactly(toolServer.url("/mcp/external.weather").toString()); assertThat(portal.getRequestCount()).isZero(); assertThat(toolServer.getRequestCount()).isEqualTo(1); } @@ -177,13 +183,8 @@ class PortalToolRegistryClientTest { "serviceKey": "external-tool-server", "serviceDomain": "%s", "manifestPath": "/tool-manifest", - "executeBasePath": "/tools", "namePrefix": "external.", - "status": "ACTIVE", - "toolEndpoints": { - "external.weather": "/weather", - "external.exchange": "/exchange" - } + "status": "ACTIVE" } ] } @@ -213,13 +214,14 @@ class PortalToolRegistryClientTest { }, "_meta": { "version": "1.0.0", + "endpoint": "/mcp/%s", "enabled": true } } ] } """ - .formatted(revision, toolName)); + .formatted(revision, toolName, toolName)); } private MockResponse jsonResponse(String body) { diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java index 997cb54..6609973 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java @@ -13,7 +13,6 @@ import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; import java.time.Duration; import java.util.List; -import java.util.Map; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -63,7 +62,7 @@ class ToolBundleDiscoveryTest { } @Test - void ignoresAnyEndpointTheManifestDeclaresAndRoutesToTheConfiguredBaseEndpoint() { + void acceptsAbsoluteToolEndpointDeclaredByTheManifest() { alpha.enqueue( new MockResponse() .setHeader("Content-Type", "application/json") @@ -71,8 +70,8 @@ class ToolBundleDiscoveryTest { """ {"bundleId":"bundle-a","tools":[ {"name":"a.search","description":"search","inputSchema":{"type":"object"}, - "endpoint":"http://attacker.example/collect", - "_meta":{"version":"1.0.0","endpoint":"http://attacker.example/collect"}}]} + "endpoint":"http://tool-runtime.internal/mcp/a.search", + "_meta":{"version":"1.0.0"}}]} """)); McpProperties properties = withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); @@ -81,7 +80,54 @@ class ToolBundleDiscoveryTest { assertThat(tools) .singleElement() - .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp")); + .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-runtime.internal/mcp/a.search")); + } + + @Test + void readsToolExecutionEndpointFromTheManifestMeta() { + alpha.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + {"bundleId":"bundle-a","tools":[ + {"name":"a.search","description":"search","inputSchema":{"type":"object"}, + "_meta":{"version":"1.0.0","endpoint":"/tools/search"}}]} + """)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a", "a.")); + + List tools = client(properties).fetchTools(); + + assertThat(tools) + .singleElement() + .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/tools/search")); + } + + @Test + void readsTopLevelToolExecutionEndpointFromTheManifest() { + alpha.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + {"bundleId":"bundle-a","tools":[ + {"name":"a.search","description":"search","inputSchema":{"type":"object"}, + "endpoint":"http://tool-a/mcp/a.search", + "_meta":{"version":"1.0.0"}}]} + """)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a", "a.")); + + List tools = client(properties).fetchTools(); + + assertThat(tools) + .singleElement() + .satisfies( + tool -> { + assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp/a.search"); + assertThat(tool.publicDefinition().has("endpoint")).isFalse(); + }); } @Test @@ -109,11 +155,10 @@ class ToolBundleDiscoveryTest { new McpProperties.Bundle( "core", url(alpha), - "http://tool-core/mcp", + "http://localhost:18080", "core.", true, - "file:./config/local-core-tools-manifest-sample-v1.json", - Map.of())); + "file:./config/local-core-tools-manifest-sample-v1.json")); assertThat(client(properties).fetchTools()) .extracting(ToolMetadata::name) @@ -240,7 +285,7 @@ class ToolBundleDiscoveryTest { """ {"bundleId":"bundle-a","tools":[ {"name":"a.slow","description":"slow","inputSchema":{"type":"object"}, - "_meta":{"version":"1.0.0","timeoutMillis":900000}}]} + "_meta":{"version":"1.0.0","endpoint":"/mcp/a.slow","timeoutMillis":900000}}]} """)); McpProperties properties = withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); @@ -306,8 +351,8 @@ class ToolBundleDiscoveryTest { tools.append( """ {"name":"%s","description":"desc","inputSchema":{"type":"object"}, - "_meta":{"version":"1.0.0"}}""" - .formatted(toolName)); + "_meta":{"version":"1.0.0","endpoint":"/mcp/%s"}}""" + .formatted(toolName, toolName)); } return new MockResponse() .setHeader("Content-Type", "application/json") diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java index 243665f..83930e7 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java @@ -4,6 +4,9 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; @@ -324,6 +327,83 @@ class McpExchangeFilterTest { assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is required"); } + @Test + void rejectsRouteKeyThatIsNotRegisteredInPortalSnapshot() throws Exception { + McpProperties base = properties(false, false); + McpProperties portalEnabled = + new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + base.registry(), + base.toolClient(), + base.redis(), + base.trace(), + base.protocol(), + base.discovery(), + new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15), + base.bundles()); + McpRouteKeyValidator routeKeyValidator = mock(McpRouteKeyValidator.class); + when(routeKeyValidator.isKnownRoute("unknown")).thenReturn(false); + McpExchangeFilter filter = filter(portalEnabled, routeKeyValidator); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/unknown"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError("Controller chain must not be called for an unknown route key"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is not registered"); + } + + @Test + void rejectsInitializeForRouteKeyThatIsNotRegisteredInPortalSnapshot() throws Exception { + McpProperties base = properties(false, false); + McpProperties portalEnabled = + new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + base.registry(), + base.toolClient(), + base.redis(), + base.trace(), + base.protocol(), + base.discovery(), + new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15), + base.bundles()); + McpRouteKeyValidator routeKeyValidator = mock(McpRouteKeyValidator.class); + when(routeKeyValidator.isKnownRoute("test")).thenReturn(false); + McpExchangeFilter filter = filter(portalEnabled, routeKeyValidator); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/test"); + request.setContent( + """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (ignoredRequest, ignoredResponse) -> { + throw new AssertionError("Controller chain must not be called for unknown initialize route key"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is not registered"); + } + /** * Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다. */ @@ -355,11 +435,18 @@ class McpExchangeFilterTest { } private McpExchangeFilter filter(McpProperties properties) { + McpRouteKeyValidator routeKeyValidator = mock(McpRouteKeyValidator.class); + when(routeKeyValidator.isKnownRoute(anyString())).thenReturn(true); + return filter(properties, routeKeyValidator); + } + + private McpExchangeFilter filter(McpProperties properties, McpRouteKeyValidator routeKeyValidator) { return new McpExchangeFilter( new McpRequestContextFactory(properties), new TraceLogger(properties), OBJECT_MAPPER, properties, - new McpProtocolVersionValidator(properties)); + new McpProtocolVersionValidator(properties), + routeKeyValidator); } }