From 189277a78cdf39b030bbc0431b35df763498e466 Mon Sep 17 00:00:00 2001 From: janghw Date: Fri, 14 Aug 2026 17:59:07 +0900 Subject: [PATCH] Update project functionality and configuration --- README.md | 6 +- build.gradle | 6 +- docs/architecture.md | 17 +- .../agentbuilder-v0.2/initialize-request.json | 2 +- .../initialize-response.json | 2 +- .../initialize-response.json | 8 +- .../protocol-v0.2-agentbuilder.md | 4 +- .../protocol-v0.3-streaming-policy.md | 8 +- .../protocol-v0.2-bundle-discovery.md | 2 +- docs/extension-points.md | 4 +- docs/mcp-java-sdk-adoption.md | 10 +- potal/agent-test-backend/.classpath | 18 + potal/agent-test-backend/.project | 28 + .../org.eclipse.buildship.core.prefs | 13 + .../.settings/org.eclipse.jdt.core.prefs | 4 + .../org.springframework.ide.eclipse.prefs | 2 + potal/agent-test-backend/README.md | 137 ++++ .../bin/main/application.yml | 18 + .../AgentTestBackendApplication.class | Bin 0 -> 896 bytes .../agenttest/AgentTestProperties$Mcp.class | Bin 0 -> 1563 bytes .../AgentTestProperties$Portal.class | Bin 0 -> 1714 bytes .../AgentTestProperties$ToolServer.class | Bin 0 -> 1580 bytes .../agenttest/AgentTestProperties.class | Bin 0 -> 2349 bytes .../McpProxyController$ChatRequest.class | Bin 0 -> 1437 bytes .../McpProxyController$PlannedTool.class | Bin 0 -> 1987 bytes .../McpProxyController$ToolCallRequest.class | Bin 0 -> 1861 bytes .../agenttest/McpProxyController.class | Bin 0 -> 25416 bytes .../agenttest/PortalApiExceptionHandler.class | Bin 0 -> 2194 bytes .../agenttest/PortalBundleController.class | Bin 0 -> 2196 bytes ...PortalBundleService$BundleDefinition.class | Bin 0 -> 2920 bytes ...BundleService$BundlePublicDefinition.class | Bin 0 -> 2936 bytes .../PortalBundleService$BundleRequest.class | Bin 0 -> 2905 bytes .../PortalBundleService$BundleState.class | Bin 0 -> 2403 bytes .../PortalBundleService$BundleView.class | Bin 0 -> 2720 bytes .../agenttest/PortalBundleService.class | Bin 0 -> 20327 bytes .../bin/main/static/index.html | 625 ++++++++++++++++++ potal/agent-test-backend/build.gradle | 27 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + potal/agent-test-backend/gradlew | 251 +++++++ potal/agent-test-backend/gradlew.bat | 94 +++ potal/agent-test-backend/settings.gradle | 15 + .../AgentTestBackendApplication.java | 16 + .../agenttest/AgentTestProperties.java | 16 + .../example/agenttest/McpProxyController.java | 476 +++++++++++++ .../agenttest/PortalApiExceptionHandler.java | 21 + .../agenttest/PortalBundleController.java | 45 ++ .../agenttest/PortalBundleService.java | 346 ++++++++++ .../src/main/resources/application.yml | 18 + .../src/main/resources/static/index.html | 625 ++++++++++++++++++ .../agenttest/McpProxyControllerTest.java | 123 ++++ .../PortalApiExceptionHandlerTest.java | 24 + .../agenttest/PortalBundleServiceTest.java | 83 +++ .../dap/biz/mcp/McpServerApplication.java | 2 +- .../dap/biz/mcp/config/McpProperties.java | 51 +- .../biz/mcp/context/McpRequestContext.java | 1 + .../mcp/execute/ToolArgumentValidator.java | 14 +- .../dap/biz/mcp/execute/ToolCall.java | 2 +- .../biz/mcp/execute/ToolExecutionService.java | 63 +- .../biz/mcp/execute/ToolRoutingService.java | 4 +- .../dap/biz/mcp/jsonrpc/JsonRpcException.java | 2 +- .../biz/mcp/jsonrpc/JsonRpcNotification.java | 24 + .../dap/biz/mcp/jsonrpc/JsonRpcRequest.java | 2 +- .../biz/mcp/jsonrpc/JsonRpcRequestParser.java | 12 +- .../dap/biz/mcp/jsonrpc/JsonRpcResponse.java | 4 +- .../dap/biz/mcp/method/InitializeHandler.java | 14 +- .../dap/biz/mcp/method/ToolsCallHandler.java | 8 +- .../dap/biz/mcp/method/ToolsListHandler.java | 10 +- .../ToolCatalogHealthIndicator.java | 4 +- .../registry/LocalFileToolRegistryClient.java | 24 +- .../registry/PortalToolRegistryClient.java | 332 ++++++++++ .../registry/RedisPortalRegistryCache.java | 62 ++ .../mcp/registry/RedisToolRegistryCache.java | 94 ++- .../biz/mcp/registry/ToolBundleDiscovery.java | 49 +- .../registry/ToolBundleRegistryClient.java | 6 +- .../mcp/registry/ToolListChangedEvent.java | 26 + .../dap/biz/mcp/registry/ToolMetadata.java | 17 +- .../biz/mcp/registry/ToolRegistryClient.java | 26 +- .../ToolRegistryRefreshScheduler.java | 46 +- .../biz/mcp/registry/ToolRegistryService.java | 231 +++++-- .../biz/mcp/toolclient/HttpToolClient.java | 17 +- .../dap/biz/mcp/toolclient/ToolClient.java | 21 +- .../biz/mcp/transport/http/McpController.java | 6 +- .../mcp/transport/http/McpExchangeFilter.java | 11 +- .../http/McpRequestContextFactory.java | 98 ++- src/main/resources/application-local.yml | 16 +- src/main/resources/application.yml | 15 +- .../shinhanlife/dap/biz/mcp/TestFixtures.java | 15 +- .../config/McpBundleConfigurationTest.java | 7 +- .../AgentBuilderContractExampleTest.java | 26 +- .../ToolBundleContractExampleTest.java | 12 +- .../deploy/HelmDeploymentContractTest.java | 7 +- .../execute/ToolArgumentValidatorTest.java | 2 +- .../mcp/execute/ToolExecutionServiceTest.java | 64 +- .../mcp/execute/ToolRoutingServiceTest.java | 13 + .../mcp/jsonrpc/JsonRpcRequestParserTest.java | 6 +- .../biz/mcp/method/InitializeHandlerTest.java | 35 +- .../InitializedNotificationHandlerTest.java | 2 +- .../biz/mcp/method/ToolsCallHandlerTest.java | 4 +- .../biz/mcp/method/ToolsListHandlerTest.java | 14 +- .../HealthGroupContractTest.java | 7 +- .../ToolCatalogHealthIndicatorTest.java | 3 +- .../PortalToolRegistryClientTest.java | 201 ++++++ .../registry/RedisToolRegistryCacheTest.java | 12 +- .../mcp/registry/ToolBundleDiscoveryTest.java | 5 +- .../ToolRegistryRefreshSchedulerTest.java | 35 + .../mcp/registry/ToolRegistryServiceTest.java | 123 +++- .../mcp/toolclient/HttpToolClientTest.java | 59 +- .../mcp/transport/http/McpControllerTest.java | 6 +- .../http/McpEndpointMethodContractTest.java | 21 +- .../http/McpExceptionHandlerTest.java | 12 +- .../transport/http/McpExchangeFilterTest.java | 76 ++- .../http/McpProtocolVersionValidatorTest.java | 4 +- 113 files changed, 4838 insertions(+), 348 deletions(-) create mode 100644 potal/agent-test-backend/.classpath create mode 100644 potal/agent-test-backend/.project create mode 100644 potal/agent-test-backend/.settings/org.eclipse.buildship.core.prefs create mode 100644 potal/agent-test-backend/.settings/org.eclipse.jdt.core.prefs create mode 100644 potal/agent-test-backend/.settings/org.springframework.ide.eclipse.prefs create mode 100644 potal/agent-test-backend/README.md create mode 100644 potal/agent-test-backend/bin/main/application.yml create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestBackendApplication.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$Mcp.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$Portal.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$ToolServer.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController$ChatRequest.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController$PlannedTool.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController$ToolCallRequest.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalApiExceptionHandler.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleController.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleDefinition.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundlePublicDefinition.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleRequest.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleState.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleView.class create mode 100644 potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService.class create mode 100644 potal/agent-test-backend/bin/main/static/index.html create mode 100644 potal/agent-test-backend/build.gradle create mode 100644 potal/agent-test-backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 potal/agent-test-backend/gradle/wrapper/gradle-wrapper.properties create mode 100644 potal/agent-test-backend/gradlew create mode 100644 potal/agent-test-backend/gradlew.bat create mode 100644 potal/agent-test-backend/settings.gradle create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestBackendApplication.java create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestProperties.java create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/McpProxyController.java create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/PortalApiExceptionHandler.java create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleController.java create mode 100644 potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleService.java create mode 100644 potal/agent-test-backend/src/main/resources/application.yml create mode 100644 potal/agent-test-backend/src/main/resources/static/index.html create mode 100644 potal/agent-test-backend/src/test/java/com/example/agenttest/McpProxyControllerTest.java create mode 100644 potal/agent-test-backend/src/test/java/com/example/agenttest/PortalApiExceptionHandlerTest.java create mode 100644 potal/agent-test-backend/src/test/java/com/example/agenttest/PortalBundleServiceTest.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java create mode 100644 src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java create mode 100644 src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java diff --git a/README.md b/README.md index 1c8d341..b2d6654 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Agent Builder와 Tool Service 사이의 stateless MCP 실행 계층이다. Agent ## 기술 기준 -- Java 21, Spring Boot 4.0.7, Spring MVC -- MCP Java SDK 2.0.0의 `mcp-json-jackson3`: protocol 상수·표준 result 모델·JSON Schema 검증에만 사용 +- Java 21, Spring Boot 3.5.11, Spring MVC +- MCP Java SDK 2.0.0의 `mcp-json-jackson2`: protocol 상수·표준 result 모델·JSON Schema 검증에만 사용 - Spring Data Redis: 선택적 공유 cache - Spring AI MCP Starter/transport: 사용하지 않음 @@ -59,7 +59,7 @@ $env:MCP_LOCAL_TOOL_REGISTRY_FILE='file:C:/path/local-tools.json' - Response: 항상 단일 `application/json` JSON-RPC response - `Accept: application/json, text/event-stream`: 호환 목적으로 수용하지만 SSE 경로는 제공하지 않음 - 공개 endpoint의 `GET`: `405 Method Not Allowed` -- `MCP-Protocol-Version`: `initialize` 이후 필수, 현재 `2025-06-18` +- `MCP-Protocol-Version`: `initialize` 이후 필수, 현재 `2025-11-25` - `Mcp-Session-Id`: initialize lifecycle 추적용 correlation 값이며 서버 세션이 아님 정확한 요청·응답과 오류 의미는 [Agent Builder-MCP 현재 계약 v0.3](docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md)이 정본이다. diff --git a/build.gradle b/build.gradle index 071d0c4..dc62c3b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.7' + id 'org.springframework.boot' version '3.5.11' id 'io.spring.dependency-management' version '1.1.7' } @@ -83,9 +83,9 @@ repositories { } dependencies { - // This module supplies the MCP protocol models transitively and the Jackson 3 schema validator directly. + // This module supplies the MCP protocol models transitively and the Jackson 2 schema validator directly. // The MCP server starter/transport is intentionally excluded because this project owns the /mcp HTTP contract. - implementation 'io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0' + implementation 'io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/docs/architecture.md b/docs/architecture.md index 86527cb..0997bea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,7 +66,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T | `TraceLogger` | `observability` | context의 guid/requestId를 직접 포함하는 최소 key=value 경계 로그. 사원 식별자는 기록하지 않음 | | `McpExceptionHandler` | `transport/http` | JSON parse, JSON-RPC, 예상 밖 오류의 표준 response 변환 | -Jackson 3 databind 모델은 `tools.jackson.databind.*`을 사용한다. 이 조합의 annotation API는 `com.fasterxml.jackson.annotation.*` namespace로 제공되므로, 이를 `tools.jackson.annotation.*`으로 바꾸지 않는다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다. +Spring Boot 3.5가 관리하는 Jackson 2 databind 모델과 annotation API는 `com.fasterxml.jackson.*` namespace를 사용한다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다. MCP Java SDK는 protocol 상수, 표준 result 모델과 JSON Schema validator에만 사용한다. SDK/Spring AI MCP Starter와 transport는 활성화하지 않으며 기존 `/mcp`, 보안, correlation, Registry, Tool 실행 경계를 유지한다. @@ -148,7 +148,7 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read ## Protocol version 협상과 검증 - 서버는 `mcp.protocol.supported-versions`와 `mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다. -- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다. +- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=true`를 제공한다. - 이 서버는 stateless이므로 협상 결과를 session에 저장하지 않는다. `initialize` 이후 Agent Builder는 모든 MCP HTTP 요청에 `MCP-Protocol-Version: `을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다. - header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다. @@ -156,7 +156,7 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read - `initialize`의 JSON-RPC result는 server protocol/capability 정보를 제공하고, HTTP response header `Mcp-Session-Id`에는 새 UUID를 제공한다. - Agent Builder는 이 값을 `notifications/initialized`, `tools/list`, `tools/call`의 `Mcp-Session-Id` header에 보낸다. 각 HTTP 요청은 별도 `x-request-id`를 유지한다. -- Agent Builder는 MCP 2025-06-18 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다. +- Agent Builder는 MCP 2025-11-25 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다. - `InitializedNotificationHandler`는 id 없는 notification을 HTTP 202으로 수용한다. 이는 Tool 실행 준비 상태를 메모리에 세우는 동작이 아니므로 replica 간 affinity가 필요 없다. ## Tool metadata 갱신 장애 시나리오 @@ -185,8 +185,8 @@ rolling update 중 새 Pod이 빈 catalog로 기존 정상 Pod을 대체하지 각 bundle은 이번 성공본 또는 직전 성공본이 있어야 aggregate를 확정할 수 있다. 조회 실패는 Tool 삭제로 해석하지 않으며, 성공한 매니페스트에서 빠진 경우에만 삭제를 반영한다. 이름 충돌이나 총량 상한 초과도 전체 갱신 실패로 처리한다. 동시에 여러 refresh가 들어오면 single-flight로 하나의 원천 조회 결과를 공유한다. -캐시에서 Tool을 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 더 조회한 뒤 `-32001`을 결정한다. -Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. key 형식, TTL, 공유 범위, 고가용성·보안 정책은 +캐시에서 Tool을 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 더 조회한 뒤 `-32001`을 결정한다. 반대로 캐시에는 있던 Tool이 실행 시점에 upstream 404 또는 410을 반환하면 삭제된 Tool을 아직 들고 있는 stale snapshot 신호로 보고, 현재 요청은 Tool 실행 실패로 유지한 채 해당 route의 manifest refresh를 best-effort로 즉시 시도한다. 같은 route에서 삭제된 Tool 호출이 몰릴 때 Tool Server manifest 호출이 폭증하지 않도록 짧은 cooldown을 적용한다. +Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. Tool snapshot은 route별 key(`key-prefix:identity:v2:route:{routeToken}`)로 분리해 서로 다른 route의 Tool 목록이 섞이지 않게 하며, Portal registry fallback key와도 분리한다. key 형식, TTL, 공유 범위, 고가용성·보안 정책은 아직 확정하지 않았으며 [extension-points.md](extension-points.md#운영-적용-전-필수-보완)에서 합의한다. 현재 구현값은 운영 계약이나 장기 설계 결정이 아니다. @@ -194,6 +194,10 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. 로컬 Agent Builder 연동 검증도 실제 Tool Service와 같은 매니페스트 조회 흐름을 먼저 사용한다. `application-local.yml`의 bundle URL을 조회하고, **처음 조회가 실패했을 때만** `fallback-manifest-file`의 manifest sample을 snapshot으로 채택한다. 기본 sample은 프로젝트 루트의 `config/local-core-tools-manifest-sample-v1.json`이다. 원격 조회가 이후 성공하면 즉시 원격 목록으로 교체하며, 이미 확보한 원격 성공본은 local sample로 덮어쓰지 않는다. +## Portal Registry and Tool manifest refresh + +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 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다. + 노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 설정된 `base-endpoint`에 요청 name을 path segment로 붙여 `tools/call`을 POST한다. 이 fixture는 연동 확인용이며 실제 고객·계약·수납·지급 데이터를 담지 않는다. @@ -214,3 +218,6 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. - correlation 변경: header extractor → filter/context 정리 → response/downstream header - 공개 path/배포 변경: topology의 path 유일성 → Route host/path/Service → ConfigMap endpoint → Controller·Filter → Agent Builder 등록 URL - 최종 확인: `.\gradlew.bat clean check`, `bootJar`, 실행 JAR의 initialize → notification → tools/list 흐름 +## Tool list change notification + +`initialize`는 `capabilities.tools.listChanged=true`를 선언한다. 배경 Registry refresh가 기존 route snapshot과 다른 Tool 목록을 성공적으로 확보하면 `ToolListChangedEvent`가 표준 `notifications/tools/list_changed` JSON-RPC notification envelope를 만든다. 현재 HTTP 단발 응답 transport는 notification을 직접 push하지 않으며, SSE/Streamable HTTP 전송 계층이 추가되면 이 이벤트를 route별 Agent 연결에 전달하고 Agent Builder가 `tools/list`를 다시 호출한다. diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json index 2f88d03..8691731 100644 --- a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-request.json @@ -3,7 +3,7 @@ "id": 1, "method": "initialize", "params": { - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "toolbox-executor", diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json index 2e92693..b53bf6a 100644 --- a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.2/initialize-response.json @@ -2,7 +2,7 @@ "jsonrpc": "2.0", "id": 1, "result": { - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "serverInfo": {}, "capabilities": {} } diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json index 0a7c70f..fe6b2d2 100644 --- a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/initialize-response.json @@ -2,15 +2,15 @@ "jsonrpc": "2.0", "id": 1, "result": { - "protocolVersion": "2025-06-18", + "protocolVersion": "2025-11-25", "capabilities": { "tools": { - "listChanged": false + "listChanged": true } }, "serverInfo": { - "name": "shl-axhub-mcp-server", - "title": "SHL AX HUB MCP Server", + "name": "shl-axhub-mcp-server-external", + "title": "SHL AX HUB MCP Server (EXTERNAL)", "version": "1.0.0" } } diff --git a/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md b/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md index dddbda3..72a9ec6 100644 --- a/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md +++ b/docs/contracts/agent-builder-mcp/protocol-v0.2-agentbuilder.md @@ -6,7 +6,7 @@ - 기준일: 2026-07-16 - 구현 endpoint: `POST /mcp` - JSON-RPC: `2.0` -- protocolVersion: `2025-06-18` +- protocolVersion: `2025-11-25` > 이 문서는 교체된 고정 endpoint 시점의 이력이다. 현재 공개 URL과 path 처리는 [v0.3](protocol-v0.3-streaming-policy.md)과 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)을 따른다. @@ -23,7 +23,7 @@ Agent Builder는 연결 초기화 시 [요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 전송한다. 응답은 [응답 예시](examples/agentbuilder-v0.2/initialize-response.json)처럼 `jsonrpc`, `id`, `result.protocolVersion`만 의미 있는 값을 가진다. `serverInfo`와 `capabilities`는 빈 객체다. -서버는 protocol version으로 `2025-06-18`을 반환한다. 현재 `MCP-Protocol-Version` HTTP 헤더의 수신·검증은 범위 밖이다. +서버는 protocol version으로 `2025-11-25`을 반환한다. 현재 `MCP-Protocol-Version` HTTP 헤더의 수신·검증은 범위 밖이다. ## notifications/initialized diff --git a/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md index 909531a..2011810 100644 --- a/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md +++ b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md @@ -5,7 +5,7 @@ - 공개 endpoint: `POST https://{mcpHost}{publicPath}` - 컨테이너 endpoint: 공개 URL과 동일한 `POST {publicPath}` - JSON-RPC: `2.0` -- protocolVersion: `2025-06-18` +- protocolVersion: `2025-11-25` 이 계약의 현재 구현은 stateless MCP 실행 계층의 transport를 동기 JSON으로 고정한다. 현재 in-memory snapshot의 표준 Tool name metadata를 조회해 확정된 endpoint로 POST하며, `Mcp-Session-Id`는 lifecycle correlation 값일 뿐 서버는 initialize 성공 시 이를 발급하지만 대화·readiness 상태를 저장하지 않는다. @@ -18,7 +18,7 @@ - `Accept`는 수용 가능 형식의 선언이며, `text/event-stream`이 포함되어도 응답 transport를 바꾸지 않는다. - 독립적인 server-push SSE channel은 제공하지 않으므로 공개 endpoint의 `GET`은 `405 Method Not Allowed`다. - `initialize` 요청에는 `MCP-Protocol-Version` header를 요구하지 않는다. -- `initialize` 이후 `notifications/initialized`, `tools/list`, `tools/call` 요청에는 정확히 `MCP-Protocol-Version: 2025-06-18`이 필수다. `version` 등 임의 header는 대체하지 않는다. header가 없거나 지원하지 않는 값이면 server는 JSON-RPC body 대신 HTTP `400 Bad Request`와 `error`, `message`, `supportedVersions`, `guid`를 가진 JSON 오류 body를 반환한다. +- `initialize` 이후 `notifications/initialized`, `tools/list`, `tools/call` 요청에는 정확히 `MCP-Protocol-Version: 2025-11-25`이 필수다. `version` 등 임의 header는 대체하지 않는다. header가 없거나 지원하지 않는 값이면 server는 JSON-RPC body 대신 HTTP `400 Bad Request`와 `error`, `message`, `supportedVersions`, `guid`를 가진 JSON 오류 body를 반환한다. ## 호출자 식별 header @@ -40,13 +40,13 @@ ## initialize와 notification -`initialize`는 [v0.2 요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 그대로 사용하며, 응답은 [v0.3 응답 예시](examples/agentbuilder-v0.3/initialize-response.json)처럼 원 요청 `id`, `protocolVersion: 2025-06-18`, `serverInfo(name/title/version)`, `capabilities.tools.listChanged: false`를 반환한다. HTTP response header에는 새 UUID `Mcp-Session-Id`가 포함된다. Agent Builder는 응답 version을 이후 모든 HTTP 요청의 `MCP-Protocol-Version` header에 사용하고, session ID를 `notifications/initialized` 및 이후 Tool 요청의 correlation header로 보낸다. MCP 2025-06-18 lifecycle에 따라 Agent Builder는 `notifications/initialized`를 반드시 보내고 두 header를 포함한다. 서버는 notification을 HTTP `202 Accepted`와 빈 body로 수용하되 stateless 원칙상 수신 여부를 저장하거나 이후 요청을 차단하는 readiness gate로 사용하지 않는다. +`initialize`는 [v0.2 요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 그대로 사용하며, 응답은 [v0.3 응답 예시](examples/agentbuilder-v0.3/initialize-response.json)처럼 원 요청 `id`, `protocolVersion: 2025-11-25`, `serverInfo(name/title/version)`, `capabilities.tools.listChanged: false`를 반환한다. HTTP response header에는 새 UUID `Mcp-Session-Id`가 포함된다. Agent Builder는 응답 version을 이후 모든 HTTP 요청의 `MCP-Protocol-Version` header에 사용하고, session ID를 `notifications/initialized` 및 이후 Tool 요청의 correlation header로 보낸다. MCP 2025-11-25 lifecycle에 따라 Agent Builder는 `notifications/initialized`를 반드시 보내고 두 header를 포함한다. 서버는 notification을 HTTP `202 Accepted`와 빈 body로 수용하되 stateless 원칙상 수신 여부를 저장하거나 이후 요청을 차단하는 readiness gate로 사용하지 않는다. ## tools/list `tools/list`는 `result.tools`에 현재 snapshot의 공개 Tool 필드(`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`)를 반환한다. `_meta`의 version, endpoint, HTTP method, timeout, cache 설정은 실행·운영 metadata이므로 MCP 공개 응답에 포함하지 않는다. -현재 `tools/call`은 `structuredContent`를 반환하거나 Tool 응답을 `outputSchema`로 검증하지 않는다. 따라서 `outputSchema`를 가진 Tool 정의를 그대로 노출하는 동작은 현재 코드의 사실이지만 MCP 2025-06-18의 구조화 출력 계약을 완전히 충족하지 않는다. 운영 Tool은 구조화 출력 지원이 도입되기 전까지 `outputSchema`를 생략해야 한다. +현재 `tools/call`은 `structuredContent`를 반환하거나 Tool 응답을 `outputSchema`로 검증하지 않는다. 따라서 `outputSchema`를 가진 Tool 정의를 그대로 노출하는 동작은 현재 코드의 사실이지만 MCP 2025-11-25의 구조화 출력 계약을 완전히 충족하지 않는다. 운영 Tool은 구조화 출력 지원이 도입되기 전까지 `outputSchema`를 생략해야 한다. 원천은 profile이 정한다. local은 Tool Service 매니페스트를 먼저 조회하고 최초 실패 시 `config/local-core-tools-manifest-sample-v1.json` fallback을 사용한다(파일이 곧 목록이므로 여기에 Tool 이름을 옮겨 적지 않는다). 운영은 설정된 Tool Service 매니페스트뿐이다. 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 8836e1c..00c719e 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 @@ -190,7 +190,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기 그대로 공개한다. `_meta`는 공개하지 않는다. 현재 MCP의 `tools/call`은 `content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다. -MCP 2025-06-18에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는 +MCP 2025-11-25에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는 구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다. ## 6. MCP의 조회 동작 diff --git a/docs/extension-points.md b/docs/extension-points.md index 8d2e131..1bb1bc4 100644 --- a/docs/extension-points.md +++ b/docs/extension-points.md @@ -59,7 +59,7 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one 1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가 2. 환경별 namespace 명명 규칙과 Agent Builder namespace. 후자는 Route를 우회한 Pod 직접 접근의 허용 출처이므로 [ADR-0006](decisions/ADR-0006-no-authentication-in-mcp.md)의 전제와 직결된다 -3. 사내 Nexus에 `io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0`과 Spring Boot 4.0.7이 있는가. +3. 사내 Nexus에 `io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0`과 Spring Boot 3.5.11가 있는가. 없으면 라이브러리 반입이 선행되어야 한다 4. 사내 registry의 JDK 21 빌드·실행 이미지 이름. 현재 `Dockerfile`은 외부 이미지를 쓴다 5. 소스 개행 표준(CRLF)과 `gradlew`의 관계. @@ -77,7 +77,7 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one | 관측성 | 경계 로그와 bundle Actuator 제공 | Micrometer/OpenTelemetry/SIEM 지표와 경보 기준 | | 감사 | 일반 애플리케이션 로그만 제공 | 보존 대상·기간·암호화·위변조 방지·유실 정책 확정 후 durable sink | | 용량 | request body 1 MiB 제한 | response 크기, JSON depth, 동시 실행 수, connection pool 부하 기준 | -| Redis | 요청 경로 밖의 선택 cache. 현재 코드의 key 형식·TTL·활성 기본값은 임시 구현값 | Redis 사용 여부, key namespace·schema version·TTL·공유 범위, TLS/ACL, Sentinel/Cluster, rolling upgrade 정책 | +| Redis | 요청 경로 밖의 선택 cache. Tool snapshot cache는 route별 key(`key-prefix:identity:v2:route:{routeToken}`)로 분리하고, Portal registry fallback key와도 분리한다. Portal registry fallback key 기본값은 `axhub:mcp:portal-registry`이며 운영에서는 `mcp.redis.portal-registry-key`로 포털 저장 key와 반드시 맞춘다. | Redis 사용 여부, key namespace·schema version·TTL·공유 범위, TLS/ACL, Sentinel/Cluster, rolling upgrade 정책 | | 종료 | Spring graceful shutdown | 신규 요청 차단과 진행 중 Tool 호출 drain 검증 | | 가용성 | test·prod critical의 replica·PDB·노드 분산 values를 정적 테스트가 검사하고 usable snapshot으로 readiness 판정. 공개 Route도 배포마다 분리 | `helm lint/template`, 노드 분산 실제 확인, 배포 창 분리, 쿼터 산정. 공유 ingress·DNS 장애는 path 분할로 막히지 않는다 | diff --git a/docs/mcp-java-sdk-adoption.md b/docs/mcp-java-sdk-adoption.md index 877dbdb..e0f94b6 100644 --- a/docs/mcp-java-sdk-adoption.md +++ b/docs/mcp-java-sdk-adoption.md @@ -1,8 +1,8 @@ # MCP Java SDK 선택적 도입 설계 - 상태: 적용 완료 -- 적용 버전: `io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0` -- 대상 런타임: Java 21, Spring Boot 4.0.7 +- 적용 버전: `io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0` +- 대상 런타임: Java 21, Spring Boot 3.5.11 - 적용 원칙: 외부 계약과 AX HUB 고유 실행 경계는 유지하고, 표준 프로토콜 모델과 JSON Schema 검증만 SDK에 위임한다. ## 1. 도입 결론 @@ -11,7 +11,7 @@ Agent Builder와 합의한 동기 JSON, `Mcp-Session-Id`, protocol version HTTP 400, trace 계약을 이미 구현하고 있으므로 SDK transport를 함께 활성화하면 같은 endpoint에 두 프로토콜 처리 경로가 생길 수 있기 때문이다. -대신 실제 사용 모듈인 `mcp-json-jackson3`에 직접 의존한다. 이 모듈이 `mcp-core`를 전이 제공하므로 aggregate artifact를 별도로 선언하지 않는다. 적용 범위는 다음과 같다. +대신 실제 사용 모듈인 `mcp-json-jackson2`에 직접 의존한다. 이 모듈이 `mcp-core`를 전이 제공하므로 aggregate artifact를 별도로 선언하지 않는다. 적용 범위는 다음과 같다. | 적용 영역 | SDK 타입/기능 | 기존 코드에서의 사용 위치 | |---|---|---| @@ -87,7 +87,7 @@ SDK 모델은 handler의 표준 MCP payload를 만드는 데만 사용한다. SD | 위험 | 회피 방식 | |---|---| -| SDK Starter가 기존 `/mcp`와 충돌 | Starter를 사용하지 않고 core 모델과 Jackson 3 validator만 의존 | +| SDK Starter가 기존 `/mcp`와 충돌 | Starter를 사용하지 않고 core 모델과 Jackson 2 validator만 의존 | | SDK가 Tool 검증 실패를 `isError=true`로 바꿈 | SDK server handler를 사용하지 않고 검증 실패를 기존 `-32602 Invalid params`로 변환 | | 기존 required/type 오류 문구 변경 | 공개된 기본 검증을 SDK보다 먼저 실행해 기존 메시지를 그대로 유지 | | `Mcp-Session-Id`/protocol header 동작 변경 | 기존 filter, controller, validator를 유지 | @@ -129,7 +129,7 @@ contract test를 먼저 추가한다. SDK 버전을 올릴 때는 다음을 모두 확인한다. 1. Spring Boot/Java/MCP Java SDK 조합의 dependency resolution -2. SDK `McpSchema` 필드와 Jackson 3 직렬화 변경 여부 +2. SDK `McpSchema` 필드와 Jackson 2 직렬화 변경 여부 3. initialize, tools/list, tools/call 성공·실패 JSON의 기존 예제 일치 4. JSON-RPC 오류 code/message/data 및 HTTP status 5. `Accept`에 `text/event-stream`이 있어도 JSON 응답을 유지하는지 diff --git a/potal/agent-test-backend/.classpath b/potal/agent-test-backend/.classpath new file mode 100644 index 0000000..be88c88 --- /dev/null +++ b/potal/agent-test-backend/.classpath @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/potal/agent-test-backend/.project b/potal/agent-test-backend/.project new file mode 100644 index 0000000..f5751fa --- /dev/null +++ b/potal/agent-test-backend/.project @@ -0,0 +1,28 @@ + + + agent-test-backend + Project agent-test-backend created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + org.springframework.ide.eclipse.boot.validation.springbootbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/potal/agent-test-backend/.settings/org.eclipse.buildship.core.prefs b/potal/agent-test-backend/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e479558 --- /dev/null +++ b/potal/agent-test-backend/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments= +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home= +jvm.arguments= +offline.mode=false +override.workspace.settings=false +show.console.view=false +show.executions.view=false diff --git a/potal/agent-test-backend/.settings/org.eclipse.jdt.core.prefs b/potal/agent-test-backend/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..e9186c3 --- /dev/null +++ b/potal/agent-test-backend/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 +org.eclipse.jdt.core.compiler.compliance=21 +org.eclipse.jdt.core.compiler.source=21 diff --git a/potal/agent-test-backend/.settings/org.springframework.ide.eclipse.prefs b/potal/agent-test-backend/.settings/org.springframework.ide.eclipse.prefs new file mode 100644 index 0000000..a12794d --- /dev/null +++ b/potal/agent-test-backend/.settings/org.springframework.ide.eclipse.prefs @@ -0,0 +1,2 @@ +boot.validation.initialized=true +eclipse.preferences.version=1 diff --git a/potal/agent-test-backend/README.md b/potal/agent-test-backend/README.md new file mode 100644 index 0000000..b1875f0 --- /dev/null +++ b/potal/agent-test-backend/README.md @@ -0,0 +1,137 @@ +# Agent Test Backend / Portal PoC + +Browser-based PoC tester and hardcoded Portal Registry screen for: + +```text +Browser -> Agent Test Backend -> MCP Server -> Tool Server +``` + +## Ports + +```text +Tool Server http://localhost:9092 +MCP Server http://localhost:8080/mcp +Agent Test Backend http://localhost:7070 +``` + +## STS Run + +Import this folder as an existing Gradle project: + +```text +C:\Users\hyo\Documents\Codex\agent-test-backend +``` + +Run `com.example.agenttest.AgentTestBackendApplication`. + +Default environment: + +```text +AGENT_TEST_PORT=7070 +MCP_ENDPOINT_URL=http://localhost:8080/mcp +MCP_PROTOCOL_VERSION=2025-11-25 +TOOL_MANIFEST_URL=http://localhost:9092/tool-manifest +TOOL_SERVER_API_KEY=tool-server-key +PORTAL_REGISTRY_REVISION=1 +PORTAL_ROUTE_KEY=external +TOOL_SERVICE_DOMAIN=http://localhost:9092 +``` + +Open: + +```text +http://localhost:7070 +``` + +## Recommended Verification Order + +1. Start Tool Server in STS. +2. Start MCP Server with Tool Server environment values. +3. Start Agent Test Backend. +4. Open `http://localhost:7070`. +5. Click `Initialize`, `Tools/List`, then run `Agent Chat` or `Tools/Call`. + +## Portal PoC Scope + +The current screen intentionally uses hardcoded registry data instead of DB tables: + +```text +MCP Route external -> http://localhost:8080/mcp +Tool Service external-tools -> http://localhost:9092/tool-manifest +Mapping external -> external-tools +``` + +The MCP-facing Portal Registry API is: + +```text +GET http://localhost:7070/api/portal/registry/external +``` + +For this PoC, MCP can use these IntelliJ environment variables after applying +`C:\Users\hyo\Documents\Codex\mcp-portal-registry.patch` to the MCP project: + +```text +MCP_DISCOVERY_ENABLED=true +MCP_PORTAL_ENABLED=true +MCP_PORTAL_ROUTE_KEY=external +MCP_PORTAL_REGISTRY_URL=http://localhost:7070/api/portal/registry/external +MCP_REGISTRY_REFRESH_INTERVAL_SECONDS=60 +``` + +`POST /api/agent/chat` is a first Agent Backend skeleton. It currently uses a simple rule-based +planner so it can run without an OpenAI API key: + +```text +"서울 날씨 알려줘" -> external.weather_lookup +"달러 환율 알려줘" -> external.exchange_rate +``` + +Later, replace the rule-based planner with Codex/OpenAI model selection while keeping the same MCP +`tools/list` and `tools/call` boundary. + +## Portal Tool Bundle Management + +The Portal now manages both seeded pull-discovery bundles through one API and UI flow: + +```text +external-tools -> http://localhost:9092/tool-manifest +business-tools -> http://localhost:9090/tool-manifest +``` + +Bundle APIs: + +```text +GET /api/portal/bundles +POST /api/portal/bundles +PUT /api/portal/bundles/{bundleId} +POST /api/portal/bundles/{bundleId}/sync +``` + +The API key is write-only. Read responses expose only `apiKeyConfigured`, and logs never contain +the key value. Manifest synchronization validates the configured Bundle ID, Tool name prefix, +required metadata objects, and the configured execution endpoint for every mapped Tool. + +Business Tool endpoint mappings are owned by the Portal/MCP configuration rather than the +manifest: + +```text +business.customer_search -> POST http://localhost:9090/internal/tools/customer-search +business.order_status -> POST http://localhost:9090/internal/tools/order-status +business.ticket_create -> POST http://localhost:9090/internal/tools/ticket-create +``` + +The Bundle definitions and synchronized snapshots are currently held in memory because this PoC +does not include a database dependency or an existing schema/migration framework. Therefore no DB +migration is required. Restarting the Portal restores the two seeded definitions and clears cached +manifest snapshots. For persistent operation, the `PortalBundleService` store is the boundary to +replace with the project's chosen repository and migration framework. + +Start the business Tool server with pull discovery only: + +```text +TOOL_AUTO_REGISTER_ENABLED=false +TOOL_HEARTBEAT_ENABLED=false +TOOL_SERVER_PORT=9090 +``` + +After starting the Tool server, open the Portal and click `Sync Manifest` for `business-tools`. diff --git a/potal/agent-test-backend/bin/main/application.yml b/potal/agent-test-backend/bin/main/application.yml new file mode 100644 index 0000000..7c5fa8f --- /dev/null +++ b/potal/agent-test-backend/bin/main/application.yml @@ -0,0 +1,18 @@ +server: + port: ${AGENT_TEST_PORT:7070} + +agent-test: + mcp: + endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp} + protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25} + tool-server: + manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest} + api-key: ${TOOL_SERVER_API_KEY:tool-server-key} + portal: + registry-revision: ${PORTAL_REGISTRY_REVISION:1} + route-key: ${PORTAL_ROUTE_KEY:cus} + tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092} + +logging: + level: + com.example.agenttest: INFO diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestBackendApplication.class b/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestBackendApplication.class new file mode 100644 index 0000000000000000000000000000000000000000..a2f5fb4e87ffe6e10069c1be32edde00eaa22f7b GIT binary patch literal 896 zcmb7CO>fgc5Ph4Zb!q}_Qz(=#m0Owvs|txrs!C}D5|RZ7SK+{E<80k+?OkiVhW;&1 zAP)QheiUNXlq3g(ge>puzL|aVW@dl<{PrEd7>@(A7!DJYb9p85B9mOCQadM0$H#tm zO1l%0TuMD37e%HL;grzuX-gL$G)IB7{w;13L}O3}I|4o5+{SH*o(SV|wZvz_9nO(oW@a zs!HV(YHeISM#^y9sIzW^sGK3ewNQE8loRUDieOUu>hA=9sx7Q3q;*P`%fkIDdb_~m zL}=nVYUC=Db2+avniCfb>HLNE&5IYl5uSiXM9&&%(T?65tpS}lSw?nk_yyh1wL7HM zyMY_@0lnV=WN*@G1GlhAh`;>2CIh;CHvEdM_sjbsc|ef?#V$Fz*u`DKTew%Rd$?am Nwef)LL;4>9zW~J8{wM$d literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$Mcp.class b/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$Mcp.class new file mode 100644 index 0000000000000000000000000000000000000000..6e2e70e7aea4ca1f05d2f946836e9cf8d19e81ab GIT binary patch literal 1563 zcmb7ETT|0O6#ll*rmc}e1qD&?0@9XC6ucLmK^O;S3NnIy(5E!rfk2v#n=PaN$p@K{ zafSzffIrIdY+7uT!pK8%HYevh-?{G3U&lWHEa8QL1jDQ&8W#U#Hv*4a_73-z;-RvZ z^l6(;n^FW^Dwl^7>rP;xm!W^pKCms%_IIqe+!3FV!D zsR20?h6bdSXwl{=(Y%R%Twz!)R4$ZixodFgT|3;>0W$OyiffwSnu$RSGt6I@V2vTg zKQ?VIWEd*MYVoGF#~oEJzSsV5m>9uLhLwx@@8*yg7hT^~FBzudjO{kXAZ<;tnne$8 zn;65LDCa5-z&cmEqP}TMyTKK;lOek$n$qE~UEQAHF4HroM}nZQgixW9cF^(>W>G-N zK#^g*Gc>a4E4RU|Rvp@wCZ;jNkg1(`U^tAY;Q9w*pIa^KRokz7G_w^U_M1WZbY3w{ z8@T;B;43_Iq#Gz99 zYrfBA#Lq(m4;hO8J;cZQrfZ&x*k?Vs7Tkk2k?NF zu!lxAfxKQ_y?uHkNv7ye5@~v8=xdTBBbh9HL-s2j5-{6FAfwBX24rG%h4h^b8K7l9 zVVNSgn#Cv`V*vOU#QyLM&;k#UsSYg;x(a-WLg@q-2aVu*8&n{s1X-p_)5jP+4@CvW zIN53{i=?hruJj$ZO4CQkmS&F7cX%e{UHX$rP#q~JdXkukOeb-VWFN&ci(Y!q;XZX@ go^*L)U|7H-xQ^~SW+7j;Yrc|2S2HF_95BL$!+T1B-w?tm50@I7028fBi_yIfh`J7D&v(v5~ZJxLP=q8`<2B8DhAr)AZVBxU4^i9Yl*ET`80 zH7B_XERyzp?(Ta*_z|soHm;i(zzv4!|FN;k&@SFrxa~6Zr9v^j**g$)$lG zbQ>fQL7cvq=rw{CqJT)|3(QYMw4jr|?LM;;T|Ni&GSK}CLK`Z8BtAER#4d#N{27v< zMN+de8aQTT&Tttx52CFWxR#*Ja5Zo(47vtD-9lp;$lBpJ<&o#AFP6r;%; zCRs0W)0E-}-F=y_7|9Hs!pw}HB9R$rJ4Me4{Rk(rZa77MI;u#6$QYdn(!O{GV|07* t-zOf!1G4c5kMV@KNm9o!gB%4ri#fcYd)`OQV1e#l-y5&o)nfal{t3FOjVJ&B literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$ToolServer.class b/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties$ToolServer.class new file mode 100644 index 0000000000000000000000000000000000000000..41cc1e67ab997d816fba3cda4bf1b28414c9c8b0 GIT binary patch literal 1580 zcmbVMTT|0O6#ll*rY*6B3JTuv0@4;FDtN)7Gl=7WRAi*`pikR&g+Q9Eo0O6NI{7i?%E#-^-ehqxpK#1 z-B-4|Sz<`=roAHe7zXo&Zuh;5vMy2}Unb&6Fl5WND^^>Ls!$bPb%gdSYuwr3%GTe3 zQUkIk3=K&7vdxyGG;=2Uah~B-zI>KMOI>43ZgFo*$H~x>FDz@GizWs!%rJLWo@FYH z_|)Q#$1s$SROU@}Th#ng;k~B0VqyeW8CK3n)6Fjt&)cr;KVz7P5{E5|KtfGnBaI&1 zFmVkxg9>cWFuWH2maMOF#T&vGin2(r%a*E%m$vTNaF>Ca*5g6Y=TiEfuXwZV!%HKN zqJaX#XlH;_%k}Msu-fGaElo^fiXl}!^1!ekO~H0|<&Lo0)=S*2J2b{+DR)}U(($|^ znh;poYl;`bt0}wbOBIDII^6T3im)~xjys96|20oxXR`@RBw^89N4BTbUXv`9k)8~L zx$L?^1+>C5Fw0Q*?|~*K(cOK-Fy1NbDK#=M$8ha*Q<|Q3L22<;Y4ew)=aa7eN5DPW z%O0BB7;<`>^+M_;C7Ga`IFj^E(bqUhMlxRfhV)lD#9)R-Kp)Np8jy+66_Pm_GC=!( z#4x zQL@!k7D-*LZ1Fp;7bg#pE>0aFvwtGxExJj?sE(8qJ#mZ$rsKFxGDET4K`*_hahE!A gpL985V3@%J`i(rqV>}^Q3NSO64=~!B?oZIA->=$w6#xJL literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties.class b/potal/agent-test-backend/bin/main/com/example/agenttest/AgentTestProperties.class new file mode 100644 index 0000000000000000000000000000000000000000..52f942fdc99c98c072cf404fc0ed0bf3e5ca39d6 GIT binary patch literal 2349 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 IUrqJ?0=!Sra{vGU literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController.class b/potal/agent-test-backend/bin/main/com/example/agenttest/McpProxyController.class new file mode 100644 index 0000000000000000000000000000000000000000..cfb93d88bc096b8938554092ce4a9df259047353 GIT binary patch literal 25416 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 Ve|!!}&+zdP8a8zs@O=@N{~u9ypvM3J literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalApiExceptionHandler.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalApiExceptionHandler.class new file mode 100644 index 0000000000000000000000000000000000000000..97ad0ff3d04fa9e352e60106af2d0314a60629e9 GIT binary patch literal 2194 zcmcIl+j84f6kW%;$ad7GHNmB&Eh!Z0G-*T#Eul^WPDqHsX^I`vVR%8dPaHL}WTfM? z`4xVES9oa~nBlcE{0jd-Sx3GzPAJSUvL&6Zz4uvr?aTT7&tHB6ki$a*F#)UM*Dd+P zt~Xq1*){1YB|~LB@B?MLxrVd;q#_&2@x4vktGY5UFe-5Fk$q@euI<&V?eZg8Q3B)3 zj_0TqfsxG2uE6M;UzJIWAYoz*;{uZf$CLM)^|B0h?6NBbQU$+aySsMa=yh8-s`i~w zV4?6YVP%P6KSC%_$rSvcW`&Kw@oIa4U6+sj;J|t;%T_3ZLm5~_88&<`lqF@WX4nle zJ2TX9U#UjdWZhGaI^uSTl2h|&9x#+2{{J193lZl{<+#?C-DoeP6qJ+>G}}_>OGqwHB)8Bt6Fwf*Xokgt^+zAJG9*h@WE-|rTl%^yu`{#~ax)@je!~O{2+PT*~xWwi~w0Y?Mz(4n!tC%+ieK@uinwuSN-bIYSVG6G9b(A_}svT zz~YH%9z@n}ZekO8j=(ZK3fvo#j8h73&@H=cvc-3&@hM3U4$h-SXL#NoRDH!6!BS#{ zR}NT3(GWJUJuu-rsZW;tW>AqEOr5~h6Ay@aT^<4}Ck}$8a7gKF?D68vj$@LR9d2#x+%Z0j;V1eXg~pj?C?K9V@mwLATg0NUuqx=C4WB95|N0Dwz5gj}Dn%|OK LuX6zGjwk*C<579h literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleController.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleController.class new file mode 100644 index 0000000000000000000000000000000000000000..4a761b6b23c4d772ef64c39a87d0f3ccf8cea135 GIT binary patch literal 2196 zcmbVNU2hvj6g}g(+1Pc`HifpK1)8)4I|&&+`iaw^G_ zLOk(@K!PCgzz^U@AX67 zYpMD=uvRBlHpAE||3Mn`e7zF}HV%DX#~z9TXP>KmC4Ck2<=36(x@!fzPG*=0tTp~0 zuchOC)75o>*-aA|yCtwuo#I@rEl}JEd%BDnoUXt_MPQ~{YnLFfP{Aq83Y=+}K!24E zIy!!)I=&WIY=m9qw^eMMJX99#p5e7OCUF0f2Hw2rn}mTcR%?xnj?@}oHmKvfQp@xM zWz(2WpZ>>Sa{^aGhOX!Qw~aoiZxMKDHw~;A=(b4=L)Z?2&?;-ffDznmgmGUcQEY<# zx3L=NgE0P19_Wtjn4l-s*j9d_?UyQwXjd=eEnM+%S)ev?+A27YH<-F^td(ViFQ!P{ zFrZ!iB4v@)YbT>Z;MT;!Hp65bt-#%h&+Y0YBCondg42ZDpW0V`%5ZNS3$bTyBp>rJ zw)b{+A8tQ;__@H!(X2I<%Z}CYk@QU-(U80cVef}5fA3fDHr`(v#j#0=JJ%*>SDWRCaGPAei&H z5s2%Pk1ANjnj0f8=iiVQfpRNMIMrzXCUpf`UF14T?Io_x&&S*gu5+uuVg3+Hzi^*>aaO>U484-u4AF&aOkz5$ zbF_Y!>s4ATYpmcJu2b|J>J4t3kNdQ7v6P&TwL`r3x|5YxLMV+xD4~iPVT7yCp}Pf2 z>-;T$1Cii^%-3RpiVdesAi0d(KIR!maXE9leuxiW_gl;SUd^q>e#=C0v^Wyo4GMh! k38eyVW+5+fcZ>3E=Q6v;9o(ht^!M0Mbu3|%>lT*&1)1Y$TmS$7 literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleDefinition.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleDefinition.class new file mode 100644 index 0000000000000000000000000000000000000000..21af85596ef71edb0c22c8540b71866876a8e000 GIT binary patch literal 2920 zcmc&#TT|Oc6#kZrZ-^LZpn*_A%gq)LH*HhWn34oYiU|-W5SozQ*7gRJ$dW6`O!C%W z)2DW(nTe+}JoE?jM|FC3WrHk~3{xKZaCBC?`<=6UF5mwB&(U81?w}k;2gA*>UCoJC zyjn9wj_(W0^Mvc=inil%bGdF+OtCGTL!&Gv{l8W5+^`JKu&p?{82VoDL!L9awV!(= z%C=KsNR$+;K*NEJljUvCF|7SO!v)W_O=(m(Tf8b5daB$qo|D_hj>!-&aaX8O%A)0> zLk?fjw6J)|6l9d`FlvRSsRic34z8DhL<+!wDIdZoL&R;6YemPdJFXddi(0)=?k2mSt1)3GuhJ%3|%XBMI^!Sp@we6q<6y- zTlH#5I6Lw_GDdcpo4edGq@)x8v4*r+4gKdvjbFBpK7>@YYfHA#(%G? zd}JgO2i!f7(Nvc#NN?9Qe1Zk^9tfr>@^T<0bg$6zLq#RsJqqU`0fd(zoW4GW+| zKRlPSDC^{YQMS|)!!JSi&4vj;&NZ{Q{w_<$6_MR#`1OB1!Wj!NyPHBMZfjV?9aRr> z@V-sq9oUs3cZfBJ5Gb3eZM*K2#XUpz*`)|hyDI64pv$)Hxt_yoz702pZ%~M1jp51( zfjM={GpZuzmql}@hIQO03MsWLFuVyy(6A2emm=ryUgMHa-N_Bxep#>O-<1!=rcL0^ z>zY^REk7QxDSpO2N{V>V7R43UhGjFOCzjFU`|Op|0t<`}esWeG>!SGaKy{b$e2 zE;705ISRg8-!n{|5QE$(kgi4tzlT&dB0L`VP{}6lq4fTYrn8 zKh%~>^o_|$Hicm|Lql>N1sMrLrUQ^fBxE!UnF&CWk&v-4Bolz7A|c~pNHzfJ!6$U1 z@bxEt3te9iKzdcAd`QP}#HN1_L1qJxbmYM@;e*WuAbrp&M}DSePaSYB44prQ_S5^> z%Fhg$kSUGpN5}yo5G}Qm+E35m=gmL_L0?KK)^pSL9g}xnF#a+^)WMqT(Fl^!h zig<`e*v1YXV;A4z3GEnA!F`Hn6vglG1AZdSm4&A$k-kly{C@Sj)$dimQ~f^myVUPd JzeC&l^IxHkjtKw& literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundlePublicDefinition.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundlePublicDefinition.class new file mode 100644 index 0000000000000000000000000000000000000000..80d433f62db758a114995f37a3c743c7080486a0 GIT binary patch literal 2936 zcmcgt+fo}x5Iw_1Hx>(sAqEpn!o|{ptza92BSJ722Vua9!Pv&Rjl=+JuXdE(6=gmo zf00zhRZ+Q$hkQUjDwUpH308|jQDs*idV6+ePWN>8^qIf^Ir$5~LlmQEVOS{2QbxSx zrLrk9{6JW)D;zhIm$u8z#fr6OiY;Lu8AUPb|K%$M(mc(~6s5h#5G#1#91VNc&z84b+prF@3|Cw!O=Vixo4h0#I!fF!_R0A(+hm9qxFft# z!lLE8O%C7Ew6J)=6l9d`GF-1i^3pVOmP^(+*(^$H&!HV_bTD2v9*g58Y3&;a6fHHI&dDRc2ZrPU_ zZexmJC$;{e_bl5qpRq&k9IBjpq2`p&X$^O9pJC-=_{=dx#4ig3D$;=n!+N-R2o&@DR0T3P3(+W_NAZH+6a?(mM>l{+}K%dEwGK z3AAEC!yF!Zm8WxdZ3y>J?&Y~nd_hz|6-#W%id__s4AocHnsC}}MN9-;l+txvo0okX zP6FQ|7sV>WjWg=9E0$}NM8+?DZKsAcJSNI0)GaW)3nt94j^rDW@prFs#i!2bx|DA! zSIEJCyii{d9b+r?X{S^fGF7jDvXwkjQ$=KOaNQ$&@m zL>Y1Ps0V;XJ&M;oO|0%|;&e|FqkEc|+|$J1o+keGH1RE2Qv}q4Pl(?cS-eW$UYZ#r z%}BTFf1>XXZ_73MM${ymLccddLvr2>G7yH`2|!{^A%kJa-2f!s6fzWs+zUVwO(DZ! z$aDbGfiEbcaQGv?g~GodfOLA9@*yp!85{pS1epmyl1;s&!d_+rkS^#{BfnA;=MFd- zj_JWEw42_~dVOw^302aleu4}T0?|^vsNM7c-mN7f2)YM^4%q;j!E6nbB2O*kDW*@H z;K4;GMhyHlU1&eFc`vjV^xr|~S^Yh}(I=|t(%H+RQhI-)iph74xeuIikyggybjv9TBr%dWNrFT<>?BE&bdj`UiM}mZ#tP{{ zGO|v47&h<(d3=Yb*uplRVF%Cgf_C(H$$g1el*RAyBYq~$Jqs^UAiY4I{C@Sj)$dim RQ~f^myVUPdze5}P^IzR=mG}Sv literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleRequest.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleRequest.class new file mode 100644 index 0000000000000000000000000000000000000000..acb032ce66184e194b87408060e05f38e1ae3959 GIT binary patch literal 2905 zcmc&$TT|Oc6#kZ*FNknypfM02c6OeT5g!|_?|?sv}axqRz?{yqL1z%3MGXcL$(I;E_9 zZj{QF%o+#M_B>hfvYU?U8P-zO-nZnobdSuUoC?0Sc4X?tAk z4bD{cOe?!?lzE`Uws~M1Ue)DUt~akPoV~!=nMK+-pU#|J5a?KT_GJPBA8P1Cm-24f za-&)*NOwowN5#k~8rH7in(Eu1RDcl;F?6ZXMGf&_G_D~Lj4o+NDs%1W%%0jarJ);| z|GlcdFKg(*IW@{?=v6!W8wU(sd_Fq&me*BwuW0B)KeO#QL1qV7QXgx$ifaPv>9uz( zs$9!3#t)6kp$esc!@P2JUBf5%TwvinT;*9NtYyPuSq!E_`MY0uDvMq&v!^_LsbL;W z@y5G*YGsyv%2=ZPtd#kUrbW#YJU4h^J*CV`T-DP%@Xva+r3%KQ1 z0uSD|F18s)%7C5dm4k6}e% z{FJxcs_mI2nGI^9zEi_0?vXhP4GRJqNVr#Eh%!tWrHoM~D3g>KN}4h!pyh2_y8eA-B?hv7 z=B-JEv>$n^eh`>GO=^n>HS!a~Pdw6_x3YJGWRm|j_;9T_6_0bl3y5o^$957&0t1Tl zIT}!0?rSo+ugT-SCX4%;?Coo^wy(+Az9z@gnxdgLd_YbYw0NGsgPfVB7Sx^kD-8YV zZ@IwVE;T7qNcl4y(s?V$a1=5eg2Y=wMx&6C5G2tOG8To5h9Jq7kclYdVhGZWk9ec# z^(TLiTz@PC>G2~KK-x|sHuFaWG9H5Twmeumda#KQqz^iC6l7}l%mL@3&`T%Ke!iKF z{9L99mC~4gj4TiVajB8ie!hO6)&mg+eFj8A(FB^ujXEezPi^!R*JqFM#hXxkWAPOW zI=I@Rf3=7F?k4(%{ujR1XKNVJ=V}<%uhcNAr#fmF(^H)_Oz4AMHB9P5u^MLdRHBBo zo=Vm*_p&+mp))RVWjetsXAmfHN`jK4D2F|iUP>RO6U+Qmv&)IXIea_20_sL+M&>E&7oUGWoX4W;wka}oLc;6Tn(J zIjY0vs(FrK?G@y=E~$7|`VM-wZOXnB&Z^ep(w@V6D#qoj{+4DLyWGQ@jw$W$tGF!f zN<(u+Xv?XXl(vk;o2w3Y-DBx`Ma2iW%C5qaMQ0-1Cp+@Zw2BY$5#jtR@TzT^Wy@m* z8uQR?tI2)A-b>Q^nu_ZZKBcvdW$|4GGx$`&jbkS!2uj6G|AU5GIU#3oOE&avf}MRmVgHw9XK5#Z>V7#_S5P8ci&}Hp zdHuMHdkPk~h<=HE(2C)DDi%>D#Oz%@eaC{8Z#mi`mSwaR!uQc9LT~35-XuF+&INzE z7@lZxaoatIgHVio#$Th!jljc0f5Zh3hfrG6b>X_>(|*;XmLQl!(T9&eG%`FgH&ncZ z5k5OLyW{9$!I0DP?BCoMS7mt#YT2@cQ!+Kz6|RD9LhfI4=A;If&f%~ij0C3MO!3J- zazQb_K$E{b%9R2*1M2x#C&6Yma7Fdh1%uF8en)($3KeHCY z3I0DQchWt+`xenhWel-xB=E*CPO`@Pd{n|1PIWQoLd;YTUPkVv3X-&P1S{3WN`+W4 zmZw90d6vZANlyNPfuHyxpq7$GTJvGlz#)W*$#Stp2KOAQAN;!9TzQH!+fz?4%IhMp zF$XI~e7; WyM@nL63Z{xT4ebp>tFf)TYmx86mc;C literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleView.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService$BundleView.class new file mode 100644 index 0000000000000000000000000000000000000000..55fe73555cd6d889019e623792098d81eccb5a47 GIT binary patch literal 2720 zcmd5;T~`}L7=8xGC#ygN1XNm;+GJ@PtM#LXrhK&}ZJtFD% zcwx^m$8)&w!b^XY$9HxUvLq1&z3}AB&dfXWykGNvLeg&|G{RBoB?$l%>FTUfA zrYrLNPN{SE?6-Ndvki>y z$c||9wEA4sbfHK;hRtm5zi`0VhOCPe$U+M-#2F^ajwg0njjB-lyy}w9nX;^L_kb(M z>^sD15Y8J=8H%rL$rc*Uevk>Q7nvmMB+h={>B{u@d!IC2LJzx`jdxolMC4$>IK zM;313;~-SXsL5@ik7Rw9E8Y-VD0=R6MYdE;JakOT%?+uKw@kVbbX`jAYsH%(4?m5& zC?xO&#eOd#Rm;;(L*&D_>D;uif_qets$C8YucATbc*pXE$cMMLxT($lQd!Cut!Cle z2kij&UpB?2@N3FxYN?`-4XTuWl+X!*uOn=SNI#Q!ofMde8cU#091-w6hRR5_nVuHStM7c&Yqd8{(jH#c3D{s&@Ziwt2rUMEsiTMnD z`ysRakYWUq8VZ@~hun`q(nBE&{gBlNWE^jkU7>aF3@EtR4_!NjPEiBu+H{>*7&j#B z6TA!bf#|5~h$-qO#%GgsISTp!_%ZO+srSx+aAUhQn*NW81BLasU7T literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService.class b/potal/agent-test-backend/bin/main/com/example/agenttest/PortalBundleService.class new file mode 100644 index 0000000000000000000000000000000000000000..c9abcd77c896bfd32b6d508bf3b70d69cdb5ee0c GIT binary patch literal 20327 zcmd6P33yc1+5h{VNisK+3xpZAfZ(7YWFsJmm?#ngNH7UV0tl#alDUMD$;>!2VR30~ z)wWt~-Ku~~)mCZUY8AuwskLrxt#xZ_wOW_fudUtf-eUcJ@40toZjy;6e*f=z{?g7p z_uR9*`*!Zl_x^M65hAMhSYA?0<(={F8f$x~I}x>NLS0rYowicxnpN>+Iuu>p6AMSJ zRx7zR(uv^(Or__9wuWk=p;%YV%8qla&NNf;;x#QxnwGXVE@3Kb($9O+k!VfQ>aw=i ztO{YlWULP3+E%V?YHz7;UdrU{&?~2yM!9A*hY~WbHPRIerF)VV(}uoJPqc6K_O0n; zB-T~eG-UXvIoPB_X{&A_HYx>#kyJXlW3{z4l8VG*Ofy~kb;e_zJ;@{}QWHwYyCa=7 z_4?8j$F@wyiJj3%ERtTxLS z^wAg^=cTbs;|HOUN#kh(XakHAktndQcEEAtTke9eFNKSVX`)GmRAkT*Ok;CEw9l%^ zKp}&snB=7*A$=rfwe)m%Sjo0fhivF?ig$*hYeUJ1+&i5`Cpu_`W)4E^dJ&s8h|Q{G zJYgl%5i3>K6i;^5q!PlTP03KVwJo09QnStKr~%c4q^mK!K{vsdNHlCE>&n;q$V1ai zno6U*bPUrmIYPOZ(U9fK@=$6sWX4C+X__EbZjwR8UaA~y>Ht3asG4fLG;2^j$b*_m zvuO@fQ9`Q-)4ZAGL#N9$Te0eVG?$L^(mbZw`2{%7#?D-mOq#ESZiSC3Xo1k{1c=|v z73FJ%3UxAIgkW}(Nek&@CSN)pkBSysNu~wE0=F$Y6Cl@9gO?Tq6 z5|LA_9Z(X$kuxTaHhbw5rrP|==P^l+N-wcCiHJgj4QgZ>X>Avi6N**~-YJ7l#coTT zzMw!9w22`YHJj8TaFuk2Vv$V{$~DQTFlUuXr%C^4S;Mh_OXA(32=-`&k;?2bs}ROi z{_`2M4!WJ~-)4o?#qaseM<;GpvNRP`8(&Oe1o}06K3{jN(ix zzKQ8cx0$rPQk_e*{y*(;8**`cK3YcSiY`M(FLGm)adZQe#f9TA#%x$I*gWmE1r#yF zOlgyPXe<0tq9@&ISz)G=2Xg>JQBACB&~|8NM^6fVCzZ-*=Xuc1#ZI4FI}N%3^13;l zPSn=aM8z>}j;GSKC(J%!c8w@ybw*P1YOPSILn|zh;23-r2x1@ema9w?CuwhMW zU;ce5AO9B2UNEme|8B^~KN)DyE{17^G~Q^^O>{GYHf$>_^&-xAVXcVzO z946`Qb)7bVm^ogqbENjvs`Y(h_-Jyg8KQ>{tqzqimE$n?30c=cr zOXyL0%uC}hbX+6_4cp={ojZJEzS=q$!jP^TEZ)tdRc_G|HU5D~&(ZTB z5(EpeiN@@*j}OT}H5oo^x#&^*;1^ALNdiB^PEi}fq7OeZ>BsakqD15&X~cjj9m0;y zV~NW*_({<#CcR2Og-~{fwpfy|gwpFG>CI@tS!knmjC(E=Kyq1!mF{#;X!g2EKchDQ z5jNk1xDH%w$~$|olq;n}e@VF#0KjV1{%Yy=PAj2RSR#*R+30PPeonuTAU2jl`rVw1 zh}Xm-U_H}}Jp1P1!CEiM~uN zPaKBJ^$ga-*IezeL~#FO(g%#IA;gcsk20N_2ejeuU{e4R!pfw-v4?3P9MW(xbg@Hp zt-;4dYnzfy=b(R>8(4!Ol=D7EcE|te~A7hJiefFnOey z2VX}h?9fsGE;Z@jI(&DESctq6x?-`~sd^ze%S)r28<$*wsqv#n<*Uo((iKMs7s82p zc$~@O8C9+VDKjz!^ZWNz;9&$t(-L3E6HT7PC}|aKibRn+L$w@``nvJlq``fnA4SY5 z0>Y6@XAZ;fxXnyoYdshYek$ath-UaGiOz;DGZ` zgni?gCYS5NP#0_(S8}zNtHAUeAEa<;@+_`_mC|wDN+iz#l#N^#To*SDMt#e46+RFE z8sbDy1xB;f6+=1U=eWLw!ySoC@$i&>V;xD+$U(e@C^%BBJ-&~m41KPjygbfN22T}H zsx`Sz0#YN4Xb6rVEuE;7Jw*@rshFo4ya;lpDVu`-;l)V0xPfWCTM7PGNtatTZ3P?I zVMW0jq&2IBqfDm_LtF-w!177z2C+D&n7mv9n*szI-Nn3uo4kz5m|fA=4AD%qOm5~D z3|qZ&Wt)_QbN0Y~UMGvKGWj%KjerPoWqg~JYzUetoyn&&3T~yg8Ex!}#gmo|VxO2Tqsbc(6<|m=V$$=hu;u~`HJ@SfnL;sN z3-fVJ& z&w+$Rtyoujv!2-~527Y_GY%k%Q#~Ci9StHSp4m7EehHJ$W!OP)Bo?-|iy9YTEEY=h zRxkJT(fVeTAx+-KD7|>PBiN#FW}`FqJd@A2`$HPB3k3+&QpU*$mr-!YWbj3ReB-S4 zGb^VF%%3*-Vn)ftm$VYmP^VQNjrI%CoQVUM%CkC_PG}MX&Tt&AflZ&obl#WFdTTfzq2F=KdOP&T?%`lBGt%p0P?^70q@WTZH0*kXH=e2{NFRoT-7J zWNq+n5D>y9l_T4EHTAA3 z4CXt<^dWVe=BATl`W*gc*Qhi2>oBU_$l8&}r2J&^-QxRDLO5d>3ZBn)x$MutgdNhX zX@g|LHE!t60dB6Nr#q250Q5R;j;VPVa1S)XZUWg>KK1`{y&;OBkN0w~mmdO}+KR`l zbj_O8jV3+A`ZG5Jv$ zHZl`8SV>!~A43A+47CqXWc1@EKOv*N9dI04exCx6nToEx$9J$;ze*v050={6Zgs*> zF2+oI;F$(Li%n$Xj7p?rljlr+mTs2T3nss)O|})2lg+TfKZLk8w%AIdCD%Uv!Iril zWhh|qPf-1G;_6^WJiH?qNd+@`hL2yR7rp#bgeSvI3Y;YkdP#KjXC}WPw$#k5+=jyw z92xUlCcn);2cfz{=}w#{p}^>7TLa35t&(xZI<1VW)mmwv4a4}$B=GSa{-u|Zln*$3 zc7~YzD@`DK8`zL{by;4{|0VMP&XJg|*#KpJ#NbB-^=5-w1o}Uk{3n6l&?Dpo4TByx z_z8pm3cFn+{^C_W!{C3wtj`kP@h_AAE&kDfP1oMh;P>&Y-C!hgB zN_`MRgWKRpgEMB$2*#7a8SOK$tYT9siF+qyr3cl?#uyR>K+|fWXcA6k4(ST*2A_73 zp$wcfW=b5v1m*xkdy>5!vaAj(y$!x5cw8`qy%)@%H+yz4Wv?g*J%sq3r!0+htsuH)yfOu@Q9xCM4* zNC;l8fI|VxGFAFv8ED5EeT=w2JJV1|y!@Tr-R;m!9AH|RgGV(DM+Uk-bNnNQ!%)Wn zE$QyI(xLXWms1a&_IL<_wECkJK$H4(oBa~_lg`CSD zPj7CI$6_6!xwGfYX~)SGCe4CArMeUC*>l@SOllQ73^f}zO`e?F6UPqWP$Y`ZW5sHs zGmXl4yA=KnH4oXEbY;$svkO4@{i%4gU2F%k>h>@&h+xYFfZp9N%fPd%T8OMd1T=l- zM%&oA>uGM!rL#LfjZ`Ozkm603Ssy0RS+U8<8a7)bvR2&1TbNO-s zA&<9{V!|l36q&qQ#5L_b^+o263&Zyle=b0u2-%@%- zl8x=1(Vnmsw(B*GVaR{DCxL>ku9fiy(LCf$B?k=xtWt#u->bk{hjauhb zpzG237LfDOn&tSB8dp!sRHrN0tyX8CXshs^ z!orzDnI|tx<+EOE{p1&F7U~KGuA3rOG>kYzb(pGCT$C@8s_%eUBLpxMY)*kdBm=rk zwOL|{VgxF!aC?ZU)j3!}o~L9$)KuL%=)i#GI7{}bI4obTK+=d)t*%hCKG_9J8B1p? z0bXSb;7(0Om(5~q-1gZlJ?b0Pg}FJwM_y67@AO*B_l2})u&{H(Xg^*SyN*J z9vjh6cSkrh4HcZ>RGpj%R##LV3*gSDeCks5S+Dxcpzr>CcwHQ|Q}sDf)8W@nSLs(- z+!a)HIYKV=d8S3f4Y~TS1=SjQUCvFR9GPLJYlq>loki@jayiNl8FtOhf2K2R@G>hS zDpS{(yo5zs9g%?lSpKnLb(v&|Tx9+0O}?MMnOpz3Tt&Hm#M4KXxJd0r9a7!+(Pq%G z<7&TV)nr;79n4%Hdd^;-_&u)sNRR_rkhl6bvjo zW%%TDYgm>_K6N=if*%Cb>MqGik=kT38F}`&{2+XOQLZoe)cxuKuL9*q=Zwx`#4uJU zCUkvJDmWY5TI@WmGnB##ikTJ>Iu9; zR#W4rGI2Su>hY-LtPr;ZvRv1`khe@3B#Tb|EhfrJS;9a#5zgnhF03DyS7OzGl7Ot0*r;wZ%r{p#1d=W(o z*^?DdqxfIbY9AIrMoJ-jX>c)=XtvUuQR$28R~ z>dT|No|01aWoIr*1GjPjeWj%wFg#;nE%p0_SW5QuOfc0Q>Q1aPzEs`m^i4L^KD8fn zjwqE{M4uNn20gbbUt8)4%8Eyu>S6T=W*=25FM{+rEXfcRV~>%s<)-?M`Yv`ylH6yY z!w^}t(!sdKRL`jIW9%$oY0r({bnYgRV}2Tk>%Gd+uP3|VHX z=Y*M_r8#DvYO3coGgp9_hG!+{t4mdxGKXEW(#5Kjocq}2D%N111|QjXu=p4oXxHFS z5O2qJq3AZFW(MXV7-)>eaK07|rBad`7ugmYc3c({J1aH8Qkzn_Fd5F&?&|NhK|K3` zQ-757!v4y~eoMq>--$KwdNv{n9H8v^Os&_0#Ba)n4+ZSDUG{p;#_MePJjjQBAL8|N zpfot-X~>kE4@-bz3(6wRmVD`wqN#XM#DiB23uvNzEQ8iW`5p#WoD!3KLE%GN;Zy|2 z7&HRyW6(d6M&U^*?vBG%{u}Kdy`KUPQkj2}zE1uoIu#v>Umvc7)X=~;KSA9~m^77+ zvX|f}jl=dt_f+hoqbm;4^!58_#zQm{&5HGvy;Rjp$L^!!AEH{^oY+f?TJU7qdjE3& z3cI7p9<-^XIcgjGn=%wBE(;Rl6&^VR>gK;3rMBp`v z%IOH2MU&}RnnH{4{1o(_3Vc`6(bP)QXdS-M*$7&MFfI&&Mra0g(@Z)SGq+PET|iZI z5k6VD1TSh`j^|fmwQHz`uE)nVyJ-&HhLyjDzPs^hhETM^rXSrxU&b9MNw;E*Fy;4NX8pMZbx)*COUMo86p!5Wu3MrOWKpx+3si>;%W$xtypjTT^ zS%&Kiyt*xN3h8?jgWsw~i${GJ5{=Nw6;Pm40dNyUr3E6e64YJ=0Xt12 zG0)ySi^NjAxp@ab13(%bg%wWb(He;{5F$aWK%-Ma{65X}(mNU>_H!U3A3R1g>m^JT zBe7Aa?vf843;EzOMHQ6?d2*(Gkb@bq;iLPBO>=;!RR;JNjVjOB&!ENZDkxo!X|rH! zh}J-!)}X#?1|2JAZngg+CCbQYe98WH}l!OP;lk*90i7gHG@%X9H* z=RAxOX|#Ke6M<(x-JC^qzDD#hP(+MXYkx$K&mp(KLGA#<(H`IvnJNzQ!c3>=MpHio zPp;h0_5Bg-0D_%BFbtbufuWnA?_FSM9P-)$c|8}bPJ+b=jo>&NEC<1bNW&KaEzY84 z_lPl8Qs%%_lx!PjiIPpJdYTOX&OJ1us+X5Nz>U4UvISDgtq{?*_h5vrFSe!L3NhQJ zceU-xlq^}NXrWndfyCQmlZ)l4fy<7w!@H&eP1dqq!;!o1pZ)0=SQ{)2> zXYHct5d0Sc+|kS7T@=XPboKI-@9`>RT4^>Tb+RcSje^9v^5NTbIV+(_o+u?71% zwTFszW5@i$v4yt)H_zC@UcO)tnND&H6N?>te{$B^vdGHq2Sn(+=u|_|WQ7AgnN>jz7bn1$Ol`mp_MA0cMx+WoQ*@ zy_!tMVJZbZj%W~~yibT=mcnwB@^ZOnzMMagg|sE+D}-ZQ3ELtzyJfVtAi!U#-p_YH z|H{z*T3LY}CYpSgdte{koUbE*&jHBG;LtAzr9KarbOqJHMJ=Z<0{L}B^A3Y10K)}cnLcQ1HKLcd~-iP*bHIa z-{Nqiwm?XANMzHduH0S)xqdiz`z_plJ9qnJH3S}727xaQ@Y8#!!0}iz^}GI^xslI^ ze&lYykJ}&QZg=7KdA-Jsd#EVDFJ&Hyh!<#YcPtDX(On?b-C)x_h)3>)$GeZ_(Kq1l z?gwMOi74X%D8PeY+g_05Ax#vUF<;Lz=If5A8+050h<^+Mji8(ODXrz>=vuxXEV3CR z*MbBHMO{$tuTOy$|!i}=RvZ69rSyeX9C@U%RA*{_w(gtu< zY(N15Ka>6oUg}{;(jzdK-=bQ24Dsx@;e;QDk9q>i{v_Q%Pea+i13CLH?L(0F2*SK) z@b6jtdkx=;{GDFr0{RJ$q?fhCCB((U{W|d~T6VmJJ~dIBf(bC7625q_-%r#ev#$t}W*uO^|pH zJTlG06ohpBhiXMq_5UEYMTcpI{vBW7HJ3pK57SiqdbAx^^6g#5Y^b32wC@5w2|*I2 z=EGdH-V;z$Th|u^)X}Z$3j=C;>-wUAn$f!68&Kt~>y3b_Y+YZ9u4-J+SAz>W=ipKt zP;+td1=Ml4lmyiAt^3spO^yL-uBjPI8@R8<~SO9aeQ6fK;*i$=n&s8j7&UQ#C;WW0Wk_AkMtcLDNmkTv{HgJ*{w zl^O9sLL_WwjIC5nXerDnRn4je>j)cR;Sb}LZ}j7J83(Wn9fnMoNUrjLTB#tr?=&M2 z8godkUjKk<14J7L$<4+`t8!GdLHZ+Nvp*r>eGjqOUtAQMo&{;T6I_o(ULkpb(tzl^ z;AjNWxX0JR4k#-ju@Y2{*Py_6ND@(Z{-$N?4exFJ_D);B!53>re(>B=HLvoQ*R1bEM8iLN`*n_v%3sLVX!k?V*;?F!7 zRL?&Ak%SWbp#xK6V~1^RS!~*}*t9v=c&S-!1703@wOX|u{;hx-sYLBi=bt{)@J#gy zO>8CKwB=vzllfK~`(dk<`lL|ptadD}jGc{&UUf;Yx~v~uNH_$pOkDJyx`GJvQQ#do zUy|A9C0TIcg&fU2UsM6ADaBxC{z4F1i#RNbK&C4QpRVi71lx#*;(r( z>@0LH7N5(#>P!8RIo-8XadxR_x{zs6J#m81c2bZ(w|_U4tE7+jWVn zU=FT^>9__y=vtK3u0v_)OQ^714@Y|glKGu5{JRho?bfKQb+|9Fa#ZfQGrx_2yz+h!IXY9Nu3I3oq21YxDk) z&`}F{fOnz2%WkU!0d=TPsRC8foDNKZ2_`VD07b=8x)lYm+hB%oM=j=NWX!j_SUWe% z+PVF(0tm>lfEB>QQHlfJT>wrzf71gr8CF3;?Oo&-=e3*0SK%7oO{3se4ys3OcYqvf ze04xQrb`7Q(0W|Aw4-~{9+HbHChE-n2cYqDfcbfpR$inzd~5@YuD;W*)~!;Erb}sMY0i& zbDWJn${3yFS}JFZaP;HM|7g>_MXz8xwS1->P7Vuu3(?~A#Q(!_HSvDdW}5lb@jITxo-VJ{Zjo({T^ffpzr^r{-XY> zTYppkRR7Ygf2;qf52OX3=kXNiR-wo66zi7HGr}`cw?=vV$PloWXNCb*S?p=httFmQ zJj->f(bME<)}veW?<)N}#?Jt0qLQL!hC_2h4_EyD{sui`BK(*FUK|5&I1 literal 0 HcmV?d00001 diff --git a/potal/agent-test-backend/bin/main/static/index.html b/potal/agent-test-backend/bin/main/static/index.html new file mode 100644 index 0000000..f47ca89 --- /dev/null +++ b/potal/agent-test-backend/bin/main/static/index.html @@ -0,0 +1,625 @@ + + + + + + AX HUB Portal PoC + + + +
+
+

AX HUB Portal PoC

+
Hardcoded Registry + Agent Backend + MCP Tool Call
+
+
Loading configuration...
+
+ +
+
+
+

Portal Registry

+

필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.

+ +
+ + +
+
+ Tool Server 설정 +
+ + + + + + + + + + + +
+ 고급 설정 + + + + + + +
+
+ + +
+
+
+
+ +
+
+ MCP 연결 테스트 + + +
+ + + +
+
+
+
+
+ +
+
+
+ Agent 테스트 + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
Agent 응답 대기 중
+
+
+ +
+
+ Tool 직접 호출 + + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+ +
+
+ 상세 응답 보기 +
Waiting...
+
+
+
+
+ + + + diff --git a/potal/agent-test-backend/build.gradle b/potal/agent-test-backend/build.gradle new file mode 100644 index 0000000..b0177cc --- /dev/null +++ b/potal/agent-test-backend/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.11' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.example' +version = '0.1.0' +description = 'agent-test-backend' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/potal/agent-test-backend/gradle/wrapper/gradle-wrapper.jar b/potal/agent-test-backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/potal/agent-test-backend/gradlew.bat b/potal/agent-test-backend/gradlew.bat new file mode 100644 index 0000000..8de1053 --- /dev/null +++ b/potal/agent-test-backend/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/potal/agent-test-backend/settings.gradle b/potal/agent-test-backend/settings.gradle new file mode 100644 index 0000000..9576414 --- /dev/null +++ b/potal/agent-test-backend/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = 'agent-test-backend' diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestBackendApplication.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestBackendApplication.java new file mode 100644 index 0000000..9f126c0 --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestBackendApplication.java @@ -0,0 +1,16 @@ +package com.example.agenttest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +public class AgentTestBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(AgentTestBackendApplication.class, args); + } +} diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestProperties.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestProperties.java new file mode 100644 index 0000000..6785715 --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/AgentTestProperties.java @@ -0,0 +1,16 @@ +package com.example.agenttest; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "agent-test") +public record AgentTestProperties(Mcp mcp, ToolServer toolServer, Portal portal) { + + public record Mcp(String endpointUrl, String protocolVersion) { + } + + public record ToolServer(String manifestUrl, String apiKey) { + } + + public record Portal(long registryRevision, String routeKey, String toolServiceDomain) { + } +} diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/McpProxyController.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/McpProxyController.java new file mode 100644 index 0000000..f12f1bb --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/McpProxyController.java @@ -0,0 +1,476 @@ +package com.example.agenttest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.time.Year; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/api") +public class McpProxyController { + + private static final Logger log = LoggerFactory.getLogger(McpProxyController.class); + private static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id"; + private static final String MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"; + private static final String TOOL_SERVER_API_KEY_HEADER = "X-Tool-Server-API-Key"; + + private final AgentTestProperties properties; + private final ObjectMapper objectMapper; + private final RestClient restClient; + private final AtomicLong ids = new AtomicLong(1); + private final PortalBundleService portalBundles; + private final AtomicReference latestSessionId = new AtomicReference<>(); + + public McpProxyController( + AgentTestProperties properties, + ObjectMapper objectMapper, + RestClient.Builder builder, + PortalBundleService portalBundles) { + this.properties = properties; + this.objectMapper = objectMapper; + this.restClient = builder.build(); + this.portalBundles = portalBundles; + } + + @GetMapping("/config") + public Map config() { + Map config = new LinkedHashMap<>(); + config.put("mcpEndpointUrl", properties.mcp().endpointUrl()); + config.put("mcpProtocolVersion", properties.mcp().protocolVersion()); + config.put("toolManifestUrl", properties.toolServer().manifestUrl()); + config.put("defaultRouteKey", defaultRouteKey()); + config.put("defaultRoutedMcpEndpointUrl", mcpEndpointUrl(defaultRouteKey())); + config.put("latestSessionId", latestSessionId.get()); + return config; + } + + @GetMapping("/registry") + public Map registry() { + String routeKey = defaultRouteKey(); + return portalBundles.screenRegistry(routeKey, mcpEndpointUrl(routeKey)); + } + + @GetMapping("/portal/registry") + public Map portalRegistry(HttpServletRequest request) { + log.info("Inbound portal aggregate registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}", + request.getMethod(), request.getRequestURI(), request.getRemoteAddr(), + request.getHeader("User-Agent"), request.getHeader("Accept")); + + Map registry = portalBundles.portalRegistry(); + log.info("Portal aggregate registry response: registryRevision={}, routes={}", + registry.get("registryRevision"), registry.get("routes")); + return registry; + } + + @GetMapping("/portal/registry/{routeKey}") + public Map portalRegistry( + @PathVariable("routeKey") String routeKey, + HttpServletRequest request) { + log.info("Inbound portal registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}", + request.getMethod(), request.getRequestURI(), request.getRemoteAddr(), + request.getHeader("User-Agent"), request.getHeader("Accept")); + + Map registry = portalBundles.portalRegistry(routeKey); + log.info("Portal registry response: routeKey={}, registryRevision={}, toolServices={}", + routeKey, registry.get("registryRevision"), registry.get("toolServices")); + return registry; + } + + @PostMapping("/portal/registry/{routeKey}/revision") + public Map bumpPortalRegistryRevision(@PathVariable("routeKey") String routeKey) { + long nextRevision = portalBundles.bumpRevision(); + log.info("Portal registry revision changed: routeKey={}, registryRevision={}", routeKey, nextRevision); + return Map.of( + "routeKey", routeKey, + "registryRevision", nextRevision); + } + + @GetMapping("/tool-manifest") + public Map toolManifest() { + log.info("Outbound tool-server request: method=GET, uri={}, headers={{{}={}}}", + properties.toolServer().manifestUrl(), TOOL_SERVER_API_KEY_HEADER, masked()); + ResponseEntity response = restClient.get() + .uri(properties.toolServer().manifestUrl()) + .header(TOOL_SERVER_API_KEY_HEADER, properties.toolServer().apiKey()) + .retrieve() + .toEntity(JsonNode.class); + log.info("Inbound tool-server response: status={}, body={}", + response.getStatusCode().value(), response.getBody()); + return response("tool-manifest", response); + } + + @PostMapping("/mcp/initialize") + public Map initialize() { + return initialize(properties.portal().routeKey()); + } + + @PostMapping("/mcp/{routeKey}/initialize") + public Map initialize(@PathVariable("routeKey") String routeKey) { + Map params = Map.of( + "protocolVersion", properties.mcp().protocolVersion(), + "capabilities", Map.of(), + "clientInfo", Map.of( + "name", "agent-test-backend", + "version", "0.1.0")); + ResponseEntity response = postMcp(routeKey, jsonRpc("initialize", params), false); + String sessionId = response.getHeaders().getFirst(MCP_SESSION_ID_HEADER); + if (sessionId != null && !sessionId.isBlank()) { + latestSessionId.set(sessionId); + } + return response("initialize", response); + } + + @PostMapping("/mcp/initialized") + public Map initialized() { + return initialized(properties.portal().routeKey()); + } + + @PostMapping("/mcp/{routeKey}/initialized") + public Map initialized(@PathVariable("routeKey") String routeKey) { + ResponseEntity response = postMcp(routeKey, notification("notifications/initialized"), true); + return response("notifications/initialized", response); + } + + @PostMapping("/mcp/tools/list") + public Map toolsList() { + return toolsList(properties.portal().routeKey()); + } + + @PostMapping("/mcp/{routeKey}/tools/list") + public Map toolsList(@PathVariable("routeKey") String routeKey) { + ResponseEntity response = postMcp(routeKey, jsonRpc("tools/list", Map.of()), true); + return response("tools/list", response); + } + + @PostMapping("/mcp/tools/call") + public Map toolsCall(@RequestBody ToolCallRequest request) { + return callTool(routeKeyForTool(request.name()), request.name(), request.arguments() == null ? Map.of() : request.arguments()); + } + + @PostMapping("/mcp/{routeKey}/tools/call") + public Map toolsCall( + @PathVariable("routeKey") String routeKey, + @RequestBody ToolCallRequest request) { + return callTool(routeKey, request.name(), request.arguments() == null ? Map.of() : request.arguments()); + } + + @PostMapping("/agent/chat") + public Map chat(@RequestBody ChatRequest request) { + PlannedTool plannedTool = plan(request.message()); + String routeKey = plannedTool.routeKey(); + Map toolCall = callTool(routeKey, plannedTool.name(), plannedTool.arguments()); + JsonNode body = objectMapper.valueToTree(toolCall.get("body")); + JsonNode toolPayload = firstToolText(body); + if (body.path("result").path("isError").asBoolean(false)) { + String toolError = toolPayload.path("text").asText("도구 호출에 실패했습니다."); + toolPayload = objectMapper.createObjectNode().put("error", toolError); + } + + Map result = new LinkedHashMap<>(); + result.put("routeKey", routeKey); + result.put("mcpEndpointUrl", mcpEndpointUrl(routeKey)); + result.put("message", request.message()); + result.put("selectedTool", plannedTool.name()); + result.put("routeDecision", "%s prefix Tool은 %s route로 전송".formatted( + toolPrefix(plannedTool.name()), routeKey)); + result.put("arguments", plannedTool.arguments()); + result.put("answer", answer(plannedTool.name(), toolPayload)); + result.put("toolResult", toolPayload); + result.put("rawMcpResponse", toolCall); + return result; + } + + private Map callTool(String routeKey, String name, Map arguments) { + Map params = Map.of("name", name, "arguments", arguments); + ResponseEntity response = postMcp(routeKey, jsonRpc("tools/call", params), true); + return response("tools/call", response); + } + + private PlannedTool plan(String message) { + return planRequest(message); + } + + static PlannedTool planRequest(String message) { + String normalized = message == null ? "" : message.toLowerCase(Locale.ROOT); + if (normalized.contains("메타 공통코드")) { + return new PlannedTool("cus", "cmm_comcode_lookup", + Map.of("groupCode", "GRP_COMM_CD", "useYn", "Y")); + } + if (normalized.contains("메타 테이블")) { + return new PlannedTool("cus", "cmm_meta_table", + Map.of("tableName", "TB_CUST_BAS", "owner", "DAPADM")); + } + if (normalized.contains("템플릿")) { + return new PlannedTool("cus", "cmm_template_url", Map.of("templateId", "TPL_001")); + } + if (normalized.contains("sol 의뢰서 상세") || normalized.contains("sol 상세")) { + return new PlannedTool("cus", "sol_request_detail", Map.of("srId", "SR-001")); + } + if (normalized.contains("sol 의뢰서") || normalized.contains("sol 목록")) { + return new PlannedTool("cus", "sol_request_list", + Map.of("status", "진행중", "period", "1개월", "target", "나의 업무")); + } + if (normalized.contains("보험금 청구")) { + return new PlannedTool("cus", "ins_insurance_processor", Map.of( + "claimNumber", "CLM20230001", "claimAmount", 1500000, "claimDate", "2026-08-12")); + } + if (normalized.contains("가입설계 한도") || normalized.contains("onnba3011")) { + return new PlannedTool("cus", "oth_onnba3011_call", Map.of( + "dalScCd", "1", "cstSucoRltyCd", "01", "csNo", "000000000001")); + } + if (normalized.contains("cus") && (normalized.contains("환율") || normalized.contains("exchange"))) { + return new PlannedTool("cus", "smp_exchange_inquiry", Map.of("currencyCode", "USD")); + } + if (normalized.contains("cus") && (normalized.contains("날씨") || normalized.contains("weather"))) { + return new PlannedTool("cus", "smp_weather_inquiry", Map.of("city", "서울")); + } + if (normalized.contains("오늘의 명언") || normalized.contains("명언")) { + return new PlannedTool("cus", "smp_quote_daily", Map.of("category", "속담")); + } + if (normalized.contains("tool 파트") || normalized.contains("툴 파트") || normalized.contains("파트 구성원")) { + return new PlannedTool("cus", "smp_team_list", Map.of("teamName", "TOOL")); + } + if (normalized.contains("공휴일") || normalized.contains("휴일") || normalized.contains("holiday")) { + return new PlannedTool("external", "external.public_holiday_lookup", Map.of( + "countryCode", "KR", + "year", Year.now().getValue())); + } + if ((normalized.contains("고객") || normalized.contains("customer")) + && !normalized.contains("티켓") && !normalized.contains("ticket")) { + return new PlannedTool("business", "business.customer_search", Map.of("keyword", "C-1001")); + } + if (normalized.contains("주문") || normalized.contains("order")) { + return new PlannedTool("business", "business.order_status", Map.of("orderId", "O-9001")); + } + if (normalized.contains("티켓") || normalized.contains("ticket")) { + return new PlannedTool("business", "business.ticket_create", Map.of( + "title", "Portal test ticket", + "priority", "normal", + "description", "Created from the Portal Agent test screen")); + } + if (normalized.contains("좌표") || normalized.contains("지오코딩") || normalized.contains("geocoding")) { + return new PlannedTool("external", "external.geocoding_lookup", Map.of("city", "Seoul", "language", "ko")); + } + if (normalized.contains("국가") || normalized.contains("나라") || normalized.contains("country")) { + return new PlannedTool("external", "external.country_info_lookup", Map.of("countryCode", "KR")); + } + if (normalized.contains("환율") || normalized.contains("달러") || normalized.contains("usd") + || normalized.contains("exchange")) { + return new PlannedTool("external", "external.exchange_rate", Map.of("from", "USD", "to", "KRW")); + } + return new PlannedTool("external", "external.weather_lookup", Map.of( + "city", city(normalized), + "timezone", "Asia/Seoul")); + } + + private static String toolPrefix(String toolName) { + int dot = toolName.indexOf('.'); + if (dot > 0) { + return toolName.substring(0, dot); + } + int underscore = toolName.indexOf('_'); + return underscore > 0 ? toolName.substring(0, underscore) : toolName; + } + + private static String city(String message) { + if (message.contains("부산") || message.contains("busan")) { + return "Busan"; + } + if (message.contains("대구") || message.contains("daegu")) { + return "Daegu"; + } + if (message.contains("인천") || message.contains("incheon")) { + return "Incheon"; + } + return "Seoul"; + } + + private JsonNode firstToolText(JsonNode mcpBody) { + JsonNode content = mcpBody.path("result").path("content"); + if (!content.isArray() || content.isEmpty()) { + return mcpBody; + } + String text = content.get(0).path("text").asText(""); + if (text.isBlank()) { + return content.get(0); + } + try { + return objectMapper.readTree(text); + } catch (Exception ignored) { + return objectMapper.createObjectNode().put("text", text); + } + } + + static String answer(String toolName, JsonNode payload) { + boolean directToolResult = !payload.has("success") && !payload.has("error"); + if (!directToolResult && !payload.path("success").asBoolean(false)) { + String error = payload.path("error").asText(); + if (error.isBlank()) { + error = payload.path("text").asText("도구 호출에 실패했습니다."); + } + return "도구 실행이 실패했습니다: " + error; + } + JsonNode data = directToolResult ? payload : payload.path("data"); + if ("external.public_holiday_lookup".equals(toolName)) { + JsonNode holidays = data.path("holidays"); + List preview = new java.util.ArrayList<>(); + if (holidays.isArray()) { + for (int index = 0; index < Math.min(holidays.size(), 5); index++) { + JsonNode holiday = holidays.get(index); + preview.add("%s %s".formatted( + holiday.path("date").asText(), + holiday.path("localName").asText(holiday.path("name").asText()))); + } + } + return "%s년 대한민국 공휴일은 총 %s일입니다.%s".formatted( + data.path("year").asText(String.valueOf(Year.now().getValue())), + holidays.isArray() ? holidays.size() : 0, + preview.isEmpty() ? "" : " 주요 공휴일: " + String.join(", ", preview)); + } + if (toolName.startsWith("business.")) { + return "%s 실행 결과: %s".formatted(toolName, data.toString()); + } + if (isCusTool(toolName)) { + return "%s 실행 결과: %s".formatted(toolName, data.toString()); + } + if ("external.geocoding_lookup".equals(toolName)) { + return "도시 좌표 조회 결과: " + data.path("results").toString(); + } + if ("external.country_info_lookup".equals(toolName)) { + return "국가 정보 조회 결과: " + data.path("countries").toString(); + } + if ("external.exchange_rate".equals(toolName)) { + return "%s 기준 %s/%s 환율은 %s입니다.".formatted( + data.path("date").asText("현재"), + data.path("base").asText("USD"), + data.path("target").asText("KRW"), + data.path("rate").asText()); + } + return "%s 현재 기온은 %s도이고 풍속은 %s입니다.".formatted( + data.path("city").asText("해당 지역"), + data.path("temperature").asText(), + data.path("windSpeed").asText()); + } + + private static boolean isCusTool(String toolName) { + return toolName.startsWith("cmm_") || toolName.startsWith("ins_") + || toolName.startsWith("oth_") || toolName.startsWith("smp_") + || toolName.startsWith("sol_"); + } + + private ResponseEntity postMcp(String routeKey, Map payload, boolean includeProtocolHeaders) { + String sessionId = includeProtocolHeaders ? latestSessionId.get() : null; + String endpointUrl = mcpEndpointUrl(routeKey); + Map headers = new LinkedHashMap<>(); + headers.put("Content-Type", MediaType.APPLICATION_JSON_VALUE); + if (includeProtocolHeaders) { + headers.put(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion()); + if (sessionId != null && !sessionId.isBlank()) { + headers.put(MCP_SESSION_ID_HEADER, sessionId); + } + } + log.info("Outbound MCP request: method=POST, uri={}, headers={}, body={}", + endpointUrl, headers, payload); + + RestClient.RequestBodySpec spec = restClient.post() + .uri(endpointUrl) + .contentType(MediaType.APPLICATION_JSON); + if (includeProtocolHeaders) { + spec.header(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion()); + if (sessionId != null && !sessionId.isBlank()) { + spec.header(MCP_SESSION_ID_HEADER, sessionId); + } + } + ResponseEntity response = spec.body(payload).retrieve().toEntity(JsonNode.class); + log.info("Inbound MCP response: status={}, headers={{{}={}}}, body={}", + response.getStatusCode().value(), MCP_SESSION_ID_HEADER, + response.getHeaders().getFirst(MCP_SESSION_ID_HEADER), response.getBody()); + return response; + } + + private String mcpEndpointUrl(String routeKey) { + String baseUrl = properties.mcp().endpointUrl().replaceAll("/+$", ""); + String normalizedRouteKey = normalizeRouteKey(routeKey); + return normalizedRouteKey.isBlank() ? baseUrl : baseUrl + "/" + normalizedRouteKey; + } + + private String normalizeRouteKey(String routeKey) { + if (routeKey == null || routeKey.isBlank()) { + return defaultRouteKey(); + } + return routeKey.trim(); + } + + private String defaultRouteKey() { + String configured = properties.portal().routeKey(); + return configured == null || configured.isBlank() ? "cus" : configured.trim(); + } + + private String routeKeyForTool(String toolName) { + if (toolName == null || toolName.isBlank()) { + return defaultRouteKey(); + } + if (toolName.startsWith("business.")) { + return "business"; + } + if (isCusTool(toolName)) { + return "cus"; + } + return "external"; + } + private String masked() { + return properties.toolServer().apiKey() == null || properties.toolServer().apiKey().isBlank() + ? "" + : "********"; + } + + private Map jsonRpc(String method, Map params) { + return Map.of( + "jsonrpc", "2.0", + "id", ids.getAndIncrement(), + "method", method, + "params", params); + } + + private Map notification(String method) { + return Map.of( + "jsonrpc", "2.0", + "method", method, + "params", Map.of()); + } + + private Map response(String action, ResponseEntity response) { + Map result = new LinkedHashMap<>(); + result.put("action", action); + result.put("httpStatus", response.getStatusCode().value()); + result.put("mcpSessionId", response.getHeaders().getFirst(MCP_SESSION_ID_HEADER)); + result.put("body", response.getBody() == null ? objectMapper.createObjectNode() : response.getBody()); + return result; + } + + public record ToolCallRequest(String name, Map arguments) { + } + + public record ChatRequest(String message) { + } + + record PlannedTool(String routeKey, String name, Map arguments) { + } +} diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalApiExceptionHandler.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalApiExceptionHandler.java new file mode 100644 index 0000000..b2d287d --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalApiExceptionHandler.java @@ -0,0 +1,21 @@ +package com.example.agenttest; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +@RestControllerAdvice(assignableTypes = PortalBundleController.class) +public class PortalApiExceptionHandler { + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handle(ResponseStatusException error) { + Map body = new LinkedHashMap<>(); + body.put("status", error.getStatusCode().value()); + body.put("error", error.getStatusCode().toString()); + body.put("message", error.getReason() == null ? "Request failed" : error.getReason()); + return ResponseEntity.status(error.getStatusCode()).body(body); + } +} diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleController.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleController.java new file mode 100644 index 0000000..38ae21c --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleController.java @@ -0,0 +1,45 @@ +package com.example.agenttest; + +import com.example.agenttest.PortalBundleService.BundleRequest; +import com.example.agenttest.PortalBundleService.BundleView; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/portal/bundles") +public class PortalBundleController { + + private final PortalBundleService bundles; + + public PortalBundleController(PortalBundleService bundles) { + this.bundles = bundles; + } + + @GetMapping + public List list() { + return bundles.list(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public BundleView create(@RequestBody BundleRequest request) { + return bundles.create(request); + } + + @PutMapping("/{bundleId}") + public BundleView update( + @PathVariable("bundleId") String bundleId, + @RequestBody BundleRequest request) { + return bundles.update(bundleId, request); + } + + +} diff --git a/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleService.java b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleService.java new file mode 100644 index 0000000..a52aa88 --- /dev/null +++ b/potal/agent-test-backend/src/main/java/com/example/agenttest/PortalBundleService.java @@ -0,0 +1,346 @@ +package com.example.agenttest; + +import java.net.URI; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class PortalBundleService { + + private static final Pattern BUNDLE_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}"); + + private final Map bundles = new ConcurrentHashMap<>(); + private final AtomicLong registryRevision; + + public PortalBundleService(AgentTestProperties properties, RestClient.Builder builder) { + this.registryRevision = new AtomicLong(properties.portal().registryRevision()); + String sharedApiKey = properties.toolServer().apiKey(); + putSeed(new BundleDefinition( + "external-tools", "External Tool Server", + properties.toolServer().manifestUrl(), properties.portal().toolServiceDomain(), + "external.", true, 10, sharedApiKey, + Map.of( + "external.weather_lookup", "/mcp/external.weather_lookup", + "external.exchange_rate", "/mcp/external.exchange_rate", + "external.public_holiday_lookup", "/mcp/external.public_holiday_lookup", + "external.geocoding_lookup", "/mcp/external.geocoding_lookup", + "external.country_info_lookup", "/mcp/external.country_info_lookup"))); + putSeed(new BundleDefinition( + "business-tools", "Business Tool Server", + "http://localhost:9090/tool-manifest", "http://localhost:9090", + "business.", true, 10, sharedApiKey, + Map.of( + "business.customer_search", "/mcp/business.customer_search", + "business.order_status", "/mcp/business.order_status", + "business.ticket_create", "/mcp/business.ticket_create"))); + putSeed(new BundleDefinition( + "was-cus", "DAP WAS CUS Tool Server", + "http://localhost:8084/tool-manifest", "http://localhost:8084", + "", true, 10, null, + cusToolEndpoints())); + } + + public List list() { + return bundles.values().stream() + .map(BundleState::view) + .sorted(Comparator.comparing(view -> view.definition().bundleId())) + .toList(); + } + + public BundleView create(BundleRequest request) { + BundleDefinition definition = validated(request, null); + if (bundles.containsKey(definition.bundleId())) { + throw conflict("Bundle ID already exists: " + definition.bundleId()); + } + ensureManifestUrlUnique(definition.manifestUrl(), null); + BundleState state = new BundleState(definition); + bundles.put(definition.bundleId(), state); + registryRevision.incrementAndGet(); + return state.view(); + } + + public BundleView update(String bundleId, BundleRequest request) { + BundleState current = required(bundleId); + BundleDefinition definition = validated(request, current.definition().apiKey()); + if (!bundleId.equals(definition.bundleId())) { + throw badRequest("Bundle ID cannot be changed"); + } + ensureManifestUrlUnique(definition.manifestUrl(), bundleId); + current.update(definition); + registryRevision.incrementAndGet(); + return current.view(); + } + + public Map portalRegistry(String routeKey) { + List> services = bundles.values().stream() + .filter(state -> state.definition().enabled()) + .filter(state -> belongsToRoute(state.definition(), routeKey)) + .map(state -> service(state.definition())) + .sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey")))) + .toList(); + return Map.of( + "routeKey", routeKey, + "registryRevision", registryRevision.get(), + "toolServices", services); + } + + public Map portalRegistry() { + List> routes = bundles.values().stream() + .filter(state -> state.definition().enabled()) + .map(state -> routeKey(state.definition())) + .distinct() + .sorted() + .map(routeKey -> Map.of( + "routeKey", routeKey, + "toolServices", bundles.values().stream() + .filter(state -> state.definition().enabled()) + .filter(state -> belongsToRoute(state.definition(), routeKey)) + .map(state -> service(state.definition())) + .sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey")))) + .toList())) + .toList(); + return Map.of( + "registryRevision", registryRevision.get(), + "routes", routes); + } + + private boolean belongsToRoute(BundleDefinition definition, String routeKey) { + String normalizedRoute = routeKey == null ? "" : routeKey.trim().toLowerCase(java.util.Locale.ROOT); + return routeKey(definition).equalsIgnoreCase(normalizedRoute); + } + + private String routeKey(BundleDefinition definition) { + String prefix = definition.namePrefix(); + if (prefix == null || prefix.isBlank()) { + return definition.bundleId().startsWith("was-") + ? definition.bundleId().substring("was-".length()) + : definition.bundleId(); + } + int dot = prefix.indexOf('.'); + int underscore = prefix.indexOf('_'); + int end = dot >= 0 && underscore >= 0 ? Math.min(dot, underscore) : Math.max(dot, underscore); + return end > 0 ? prefix.substring(0, end) : prefix.replaceAll("[._]+$", ""); + } + + public Map screenRegistry(String routeKey, String mcpEndpointUrl) { + Map route = new LinkedHashMap<>(); + route.put("routeKey", routeKey); + route.put("displayName", "Portal MCP Route"); + route.put("routePath", "/mcp/" + routeKey); + route.put("mcpEndpointUrl", mcpEndpointUrl); + route.put("status", "ACTIVE"); + + List> services = list().stream().map(view -> { + BundlePublicDefinition item = view.definition(); + Map service = new LinkedHashMap<>(); + service.put("serviceKey", item.bundleId()); + service.put("displayName", item.toolServerName()); + service.put("manifestUrl", item.manifestUrl()); + service.put("baseEndpoint", item.baseUrl()); + service.put("namePrefix", item.namePrefix()); + service.put("enabled", item.enabled()); + service.put("manifestPollIntervalSeconds", item.manifestPollIntervalSeconds()); + service.put("status", item.enabled() ? "ACTIVE" : "INACTIVE"); + return service; + }).toList(); + return Map.of("mcpRoutes", List.of(route), "toolServices", services, + "mappings", services.stream().map(service -> Map.of( + "routeKey", routeKey, + "serviceKey", service.get("serviceKey"), + "status", service.get("status"), + "source", "portal-bundle-registry", + "registryRevision", registryRevision.get())).toList()); + } + + public long bumpRevision() { + return registryRevision.incrementAndGet(); + } + + private Map service(BundleDefinition definition) { + URI manifest = URI.create(definition.manifestUrl()); + String manifestPath = manifest.getRawPath(); + Map service = new LinkedHashMap<>(); + service.put("serviceKey", definition.bundleId()); + service.put("displayName", definition.toolServerName()); + service.put("serviceDomain", definition.baseUrl()); + service.put("manifestPath", manifestPath == null || manifestPath.isBlank() ? "/tool-manifest" : manifestPath); + service.put("executeBasePath", ""); + service.put("namePrefix", definition.namePrefix()); + service.put("toolEndpoints", definition.toolEndpoints()); + service.put("status", definition.enabled() ? "ACTIVE" : "INACTIVE"); + return service; + } + + private BundleDefinition validated(BundleRequest request, String existingApiKey) { + if (request == null) { + throw badRequest("Request body is required"); + } + String bundleId = requiredText(request.bundleId(), "bundleId"); + if (!BUNDLE_ID.matcher(bundleId).matches()) { + throw badRequest("Bundle ID is invalid"); + } + String name = requiredText(request.toolServerName(), "toolServerName"); + String manifestUrl = validUrl(request.manifestUrl(), "manifestUrl"); + String baseUrl = validUrl(request.baseUrl(), "baseUrl").replaceAll("/+$", ""); + String prefix = request.namePrefix() == null ? "" : request.namePrefix().trim(); + if (!prefix.isBlank() && !prefix.endsWith(".") && !prefix.endsWith("_")) { + throw badRequest("Tool name prefix must end with '.' or '_'"); + } + long interval = request.manifestPollIntervalSeconds(); + if (interval < 5 || interval > 86_400) { + throw badRequest("Manifest poll interval must be between 5 and 86400 seconds"); + } + Map endpoints = request.toolEndpoints() == null + ? Map.of() : Map.copyOf(request.toolEndpoints()); + endpoints.forEach((toolName, path) -> { + if (!TOOL_NAME.matcher(toolName).matches() + || (!prefix.isBlank() && !toolName.startsWith(prefix))) { + throw badRequest("Tool endpoint name must start with " + prefix + ": " + toolName); + } + if (path == null || !path.startsWith("/") || path.startsWith("//")) { + throw badRequest("Tool endpoint path must start with a single '/': " + toolName); + } + }); + String apiKey = request.apiKey() == null || request.apiKey().isBlank() ? existingApiKey : request.apiKey(); + return new BundleDefinition(bundleId, name, manifestUrl, baseUrl, prefix, + request.enabled(), interval, apiKey, endpoints); + } + + private Map cusToolEndpoints() { + List names = List.of( + "cmm_comcode_lookup", "cmm_customer_tool", "cmm_meta_table", "cmm_template_url", + "ins_insurance_processor", "oth_onnba3011_call", + "smp_exchange_inquiry", "smp_quote_daily", "smp_team_list", + "smp_weather_inquiry", "sol_request_detail", "sol_request_list"); + Map endpoints = new LinkedHashMap<>(); + names.forEach(name -> endpoints.put(name, "/mcp/" + name)); + return Map.copyOf(endpoints); + } + + private void ensureManifestUrlUnique(String manifestUrl, String excludedBundleId) { + boolean duplicate = bundles.values().stream().anyMatch(state -> + !state.definition().bundleId().equals(excludedBundleId) + && state.definition().manifestUrl().equalsIgnoreCase(manifestUrl)); + if (duplicate) { + throw conflict("Manifest URL already exists: " + manifestUrl); + } + } + + private BundleState required(String bundleId) { + BundleState state = bundles.get(bundleId); + if (state == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Bundle not found: " + bundleId); + } + return state; + } + + private void putSeed(BundleDefinition definition) { + bundles.put(definition.bundleId(), new BundleState(definition)); + } + + private String requiredText(String value, String field) { + if (value == null || value.isBlank()) { + throw badRequest(field + " is required"); + } + return value.trim(); + } + + private String validUrl(String value, String field) { + String text = requiredText(value, field); + try { + URI uri = URI.create(text); + if (!uri.isAbsolute() || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null) { + throw new IllegalArgumentException(); + } + return uri.toString(); + } catch (RuntimeException error) { + throw badRequest(field + " must be an absolute HTTP(S) URL"); + } + } + + private ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + public record BundleRequest( + String bundleId, + String toolServerName, + String manifestUrl, + String baseUrl, + String namePrefix, + boolean enabled, + long manifestPollIntervalSeconds, + String apiKey, + Map toolEndpoints) { + } + + public record BundleDefinition( + String bundleId, + String toolServerName, + String manifestUrl, + String baseUrl, + String namePrefix, + boolean enabled, + long manifestPollIntervalSeconds, + String apiKey, + Map toolEndpoints) { + } + + public record BundlePublicDefinition( + String bundleId, + String toolServerName, + String manifestUrl, + String baseUrl, + String namePrefix, + boolean enabled, + long manifestPollIntervalSeconds, + boolean apiKeyConfigured, + Map toolEndpoints) { + } + + public record BundleView( + BundlePublicDefinition definition, + String manifestRevision, + String lastSynchronizedAt, + String lastError, + List> tools) { + } + + private static final class BundleState { + private volatile BundleDefinition definition; + + private BundleState(BundleDefinition definition) { + this.definition = definition; + } + + private synchronized void update(BundleDefinition definition) { + this.definition = definition; + } + + private BundleDefinition definition() { return definition; } + + private BundleView view() { + BundleDefinition item = definition; + BundlePublicDefinition publicDefinition = new BundlePublicDefinition( + item.bundleId(), item.toolServerName(), item.manifestUrl(), item.baseUrl(), + item.namePrefix(), item.enabled(), item.manifestPollIntervalSeconds(), + item.apiKey() != null && !item.apiKey().isBlank(), item.toolEndpoints()); + return new BundleView(publicDefinition, null, null, null, List.of()); + } + } +} diff --git a/potal/agent-test-backend/src/main/resources/application.yml b/potal/agent-test-backend/src/main/resources/application.yml new file mode 100644 index 0000000..7c5fa8f --- /dev/null +++ b/potal/agent-test-backend/src/main/resources/application.yml @@ -0,0 +1,18 @@ +server: + port: ${AGENT_TEST_PORT:7070} + +agent-test: + mcp: + endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp} + protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25} + tool-server: + manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest} + api-key: ${TOOL_SERVER_API_KEY:tool-server-key} + portal: + registry-revision: ${PORTAL_REGISTRY_REVISION:1} + route-key: ${PORTAL_ROUTE_KEY:cus} + tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092} + +logging: + level: + com.example.agenttest: INFO diff --git a/potal/agent-test-backend/src/main/resources/static/index.html b/potal/agent-test-backend/src/main/resources/static/index.html new file mode 100644 index 0000000..f47ca89 --- /dev/null +++ b/potal/agent-test-backend/src/main/resources/static/index.html @@ -0,0 +1,625 @@ + + + + + + AX HUB Portal PoC + + + +
+
+

AX HUB Portal PoC

+
Hardcoded Registry + Agent Backend + MCP Tool Call
+
+
Loading configuration...
+
+ +
+
+
+

Portal Registry

+

필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.

+ +
+ + +
+
+ Tool Server 설정 +
+ + + + + + + + + + + +
+ 고급 설정 + + + + + + +
+
+ + +
+
+
+
+ +
+
+ MCP 연결 테스트 + + +
+ + + +
+
+
+
+
+ +
+
+
+ Agent 테스트 + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
Agent 응답 대기 중
+
+
+ +
+
+ Tool 직접 호출 + + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+ +
+
+ 상세 응답 보기 +
Waiting...
+
+
+
+
+ + + + diff --git a/potal/agent-test-backend/src/test/java/com/example/agenttest/McpProxyControllerTest.java b/potal/agent-test-backend/src/test/java/com/example/agenttest/McpProxyControllerTest.java new file mode 100644 index 0000000..89a0942 --- /dev/null +++ b/potal/agent-test-backend/src/test/java/com/example/agenttest/McpProxyControllerTest.java @@ -0,0 +1,123 @@ +package com.example.agenttest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class McpProxyControllerTest { + + @Test + void treatsDirectExternalToolPayloadAsSuccessfulResult() throws Exception { + var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree( + "{\"year\":2026,\"holidays\":[{\"date\":\"2026-01-01\",\"localName\":\"New Year\"}]}" ); + + assertThat(McpProxyController.answer("external.public_holiday_lookup", payload)) + .contains("2026", "1", "2026-01-01", "New Year") + .doesNotContain("실패"); + } + + @Test + void treatsDirectOthToolPayloadAsSuccessfulResult() throws Exception { + var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree( + "{\"codeList\":[{\"code\":\"CD001\",\"codeName\":\"진행중\"}]}"); + + assertThat(McpProxyController.answer("cmm_comcode_lookup", payload)) + .contains("cmm_comcode_lookup 실행 결과") + .contains("CD001") + .doesNotContain("실패"); + } + + @Autowired + private MockMvc mockMvc; + + @Test + void portalRegistryReturnsOkForExternalRoute() throws Exception { + mockMvc.perform(get("/api/portal/registry/external")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.routeKey").value("external")) + .andExpect(jsonPath("$.toolServices.length()").value(1)) + .andExpect(jsonPath("$.toolServices[0].serviceKey").value("external-tools")); + } + + @Test + void bumpsPortalRegistryRevision() throws Exception { + mockMvc.perform(post("/api/portal/registry/external/revision")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.routeKey").value("external")) + .andExpect(jsonPath("$.registryRevision").isNumber()); + } + + @Test + void aggregatePortalRegistryReturnsEveryRouteAndToolService() throws Exception { + mockMvc.perform(get("/api/portal/registry")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.registryRevision").isNumber()) + .andExpect(jsonPath("$.routes[?(@.routeKey=='external')].toolServices[0].serviceKey") + .value("external-tools")) + .andExpect(jsonPath("$.routes[?(@.routeKey=='business')].toolServices[0].serviceKey") + .value("business-tools")) + .andExpect(jsonPath("$.routes[?(@.routeKey=='cus')].toolServices[0].serviceKey") + .value("was-cus")); + } + + @Test + void exposesBusinessBundleAndEndpointMappingsWithoutApiKey() throws Exception { + mockMvc.perform(get("/api/portal/bundles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].definition.apiKey").doesNotExist()) + .andExpect(jsonPath("$[0].definition.apiKeyConfigured").isBoolean()) + .andExpect(jsonPath("$[0].definition.bundleId").value("business-tools")) + .andExpect(jsonPath("$[0].definition.toolEndpoints['business.customer_search']") + .value("/mcp/business.customer_search")); + + mockMvc.perform(get("/api/portal/registry/business")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.toolServices[0].serviceKey").value("business-tools")) + .andExpect(jsonPath("$.toolServices[0].toolEndpoints['business.ticket_create']") + .value("/mcp/business.ticket_create")); + } + + @Test + void selectsPublicHolidayToolForKoreanHolidayRequest() { + McpProxyController.PlannedTool plan = McpProxyController.planRequest("지금 현재 공휴일 조회해줘"); + + assertThat(plan.name()).isEqualTo("external.public_holiday_lookup"); + assertThat(plan.routeKey()).isEqualTo("external"); + assertThat(plan.arguments()).containsEntry("countryCode", "KR").containsKey("year"); + } + + @Test + void selectsEveryManifestToolFromNaturalLanguageExamples() { + assertThat(McpProxyController.planRequest("서울 날씨 조회").name()).isEqualTo("external.weather_lookup"); + assertThat(McpProxyController.planRequest("달러 환율 조회").name()).isEqualTo("external.exchange_rate"); + assertThat(McpProxyController.planRequest("서울 좌표 조회").name()).isEqualTo("external.geocoding_lookup"); + assertThat(McpProxyController.planRequest("한국 국가 정보 조회").name()).isEqualTo("external.country_info_lookup"); + assertThat(McpProxyController.planRequest("고객 검색").name()).isEqualTo("business.customer_search"); + assertThat(McpProxyController.planRequest("주문 상태 조회").name()).isEqualTo("business.order_status"); + assertThat(McpProxyController.planRequest("고객 지원 티켓 생성").name()).isEqualTo("business.ticket_create"); + assertThat(McpProxyController.planRequest("메타 공통코드 조회").name()).isEqualTo("cmm_comcode_lookup"); + assertThat(McpProxyController.planRequest("메타 테이블 조회").name()).isEqualTo("cmm_meta_table"); + assertThat(McpProxyController.planRequest("템플릿 다운로드 URL 알려줘").name()).isEqualTo("cmm_template_url"); + assertThat(McpProxyController.planRequest("SOL 의뢰서 목록 조회").name()).isEqualTo("sol_request_list"); + assertThat(McpProxyController.planRequest("SOL 의뢰서 상세 조회").name()).isEqualTo("sol_request_detail"); + assertThat(McpProxyController.planRequest("보험금 청구 처리").name()).isEqualTo("ins_insurance_processor"); + assertThat(McpProxyController.planRequest("가입설계 한도 조회").name()).isEqualTo("oth_onnba3011_call"); + assertThat(McpProxyController.planRequest("CUS 달러 환율 조회").name()).isEqualTo("smp_exchange_inquiry"); + assertThat(McpProxyController.planRequest("CUS 서울 날씨 조회").name()).isEqualTo("smp_weather_inquiry"); + assertThat(McpProxyController.planRequest("오늘의 명언 알려줘").name()).isEqualTo("smp_quote_daily"); + assertThat(McpProxyController.planRequest("TOOL 파트 구성원 조회").name()).isEqualTo("smp_team_list"); + assertThat(McpProxyController.planRequest("고객 검색").routeKey()).isEqualTo("business"); + assertThat(McpProxyController.planRequest("서울 날씨 조회").routeKey()).isEqualTo("external"); + assertThat(McpProxyController.planRequest("메타 테이블 조회").routeKey()).isEqualTo("cus"); + } +} diff --git a/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalApiExceptionHandlerTest.java b/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalApiExceptionHandlerTest.java new file mode 100644 index 0000000..eecc88c --- /dev/null +++ b/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalApiExceptionHandlerTest.java @@ -0,0 +1,24 @@ +package com.example.agenttest; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +class PortalApiExceptionHandlerTest { + + @Test + void exposesSafeValidationReasonToPortalUi() { + var response = new PortalApiExceptionHandler().handle( + new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Manifest Bundle ID does not match configuration")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).containsAllEntriesOf(Map.of( + "status", 400, + "error", "400 BAD_REQUEST", + "message", "Manifest Bundle ID does not match configuration")); + } +} diff --git a/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalBundleServiceTest.java b/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalBundleServiceTest.java new file mode 100644 index 0000000..6b7517b --- /dev/null +++ b/potal/agent-test-backend/src/test/java/com/example/agenttest/PortalBundleServiceTest.java @@ -0,0 +1,83 @@ +package com.example.agenttest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.agenttest.PortalBundleService.BundleRequest; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +class PortalBundleServiceTest { + + @Test + void exposesSeededWasCusBundleOnCusRoute() { + PortalBundleService service = new PortalBundleService(properties(), RestClient.builder()); + + var bundle = service.list().stream() + .filter(item -> item.definition().bundleId().equals("was-cus")) + .findFirst().orElseThrow(); + + assertThat(bundle.definition().manifestUrl()).isEqualTo("http://localhost:8084/tool-manifest"); + assertThat(bundle.definition().namePrefix()).isEmpty(); + assertThat(bundle.definition().manifestPollIntervalSeconds()).isEqualTo(10); + assertThat(bundle.definition().toolEndpoints()) + .containsEntry("smp_weather_inquiry", "/mcp/smp_weather_inquiry") + .containsEntry("ins_insurance_processor", "/mcp/ins_insurance_processor") + .containsEntry("cmm_customer_tool", "/mcp/cmm_customer_tool") + .hasSize(12); + assertThat(service.portalRegistry("cus").get("toolServices").toString()).contains("was-cus"); + assertThat(service.portalRegistry("external").get("toolServices").toString()).doesNotContain("was-cus"); + } + + @Test + void exposesOnlyEndpointRegistryWithoutManifestSnapshot() { + PortalBundleService service = new PortalBundleService(properties(), RestClient.builder()); + + var business = service.list().stream() + .filter(item -> item.definition().bundleId().equals("business-tools")) + .findFirst().orElseThrow(); + Map registry = service.portalRegistry("business"); + + assertThat(business.manifestRevision()).isNull(); + assertThat(business.lastSynchronizedAt()).isNull(); + assertThat(business.lastError()).isNull(); + assertThat(business.tools()).isEmpty(); + assertThat(registry.get("toolServices").toString()) + .contains("serviceDomain=http://localhost:9090") + .contains("manifestPath=/tool-manifest") + .contains("business.customer_search=/mcp/business.customer_search"); + } + + @Test + void rejectsDuplicateBundleIdManifestUrlAndInvalidUrl() { + PortalBundleService service = new PortalBundleService(properties(), RestClient.builder()); + BundleRequest duplicateId = request("business-tools", "http://localhost:9191/tool-manifest"); + BundleRequest duplicateUrl = request("another-tools", "http://localhost:9090/tool-manifest"); + BundleRequest invalidUrl = request("invalid-tools", "not-a-url"); + + assertThatThrownBy(() -> service.create(duplicateId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409 CONFLICT"); + assertThatThrownBy(() -> service.create(duplicateUrl)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409 CONFLICT"); + assertThatThrownBy(() -> service.create(invalidUrl)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400 BAD_REQUEST"); + } + + private BundleRequest request(String bundleId, String manifestUrl) { + return new BundleRequest(bundleId, "Test", manifestUrl, "http://localhost:9191", + "test.", true, 60, "", Map.of()); + } + + private AgentTestProperties properties() { + return new AgentTestProperties( + new AgentTestProperties.Mcp("http://localhost:8080/mcp", "2025-11-25"), + new AgentTestProperties.ToolServer("http://localhost:9092/tool-manifest", "secret-key"), + new AgentTestProperties.Portal(1, "external", "http://localhost:9092")); + } + +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java index 56f58e3..e86762a 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java @@ -1,7 +1,7 @@ package io.shinhanlife.dap.biz.mcp; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; -import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 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 dfb6bda..f25df00 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,6 +8,7 @@ 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; @@ -32,6 +33,7 @@ public record McpProperties( @Valid Trace trace, @Valid Protocol protocol, @Valid Discovery discovery, + @Valid Portal portal, List<@Valid Bundle> bundles) { /** @@ -39,6 +41,7 @@ public record McpProperties( */ public McpProperties { bundles = bundles == null ? List.of() : List.copyOf(bundles); + portal = portal == null ? new Portal(false, "", "", 300) : portal; } /** @@ -53,7 +56,16 @@ public record McpProperties( */ @AssertTrue(message = "mcp.discovery.enabled=true requires at least one entry in mcp.bundles") public boolean isDiscoveryTargetDeclared() { - return discovery == null || !discovery.enabled() || !enabledBundles().isEmpty(); + return discovery == null || !discovery.enabled() || portal.enabled() || !enabledBundles().isEmpty(); + } + + /** + * Portal Registry를 사용할 때 조회 URL이 선언되었는지 검증합니다. + * route key는 에이전트가 호출한 {@code /mcp/{routeKey}} 경로에서 동적으로 결정되므로 설정 기본값으로 보정하지 않습니다. + */ + @AssertTrue(message = "mcp.portal.enabled=true requires mcp.portal.registry-url") + public boolean isPortalTargetDeclared() { + return !portal.enabled() || hasText(portal.registryUrl()); } /** @@ -86,6 +98,13 @@ public record McpProperties( return true; } + /** + * 공백이 아닌 문자열인지 확인해 Portal Registry 설정 검증에 사용합니다. + */ + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + /** * initialize 응답에 공개할 MCP 서버 식별 정보 설정입니다. */ @@ -108,13 +127,25 @@ public record McpProperties( @Min(1) int connectTimeoutMillis, @Min(1) int readTimeoutMillis, @Min(1) long requestDeadlineMillis, - boolean forwardAuthorization) { + boolean forwardAuthorization, + @NotBlank String apiKey) { } /** * 선택적 Redis Tool Registry cache의 활성화 여부와 key namespace 설정입니다. */ - public record Redis(boolean enabled, @NotBlank String keyPrefix) { + public record Redis(boolean enabled, @NotBlank String keyPrefix, String portalRegistryKey) { + + /** + * Redis cache key를 정규화합니다. Tool snapshot key prefix는 기존 규칙을 유지하고, + * Portal registry fallback key는 포털이 쓰는 값을 외부 설정으로 주입받되 비어 있으면 + * MCP identity와 같은 namespace 아래 기본 key를 사용합니다. + */ + public Redis { + if (portalRegistryKey == null || portalRegistryKey.isBlank()) { + portalRegistryKey = keyPrefix + ":portal-registry"; + } + } } /** @@ -130,6 +161,13 @@ public record McpProperties( @Min(1) int maxToolTimeoutMillis) { } + /** + * 포털이 소유한 Tool Service registry 조회 설정입니다. + * MCP 요청을 직접 처리하지 않고 배경 refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다. + */ + public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshIntervalSeconds) { + } + /** * 이 MCP에 속하는 Tool Service 한 묶음의 조회 주소와 실행 주소 설정입니다. {@code baseEndpoint}는 설정에서만 오며 매니페스트 응답이 바꿀 수 없습니다. {@code fallbackManifestFile}은 최초 원격 조회 실패 시에만 쓰는 * local 검증용 원천입니다. @@ -140,7 +178,12 @@ public record McpProperties( @NotBlank String baseEndpoint, @NotBlank String namePrefix, boolean enabled, - String fallbackManifestFile) { + String fallbackManifestFile, + Map toolEndpoints) { + + public Bundle { + toolEndpoints = toolEndpoints == null ? Map.of() : Map.copyOf(toolEndpoints); + } } /** diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java index b5e2445..88625a9 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java @@ -10,6 +10,7 @@ import java.time.Instant; * 불투명 값입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다. */ public record McpRequestContext( + String routeKey, String requestId, String guid, String mcpSessionId, diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java index f0bb2f1..649ed9d 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java @@ -1,15 +1,13 @@ package io.shinhanlife.dap.biz.mcp.execute; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; - import java.util.Map; - import org.springframework.stereotype.Component; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; /** * Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid @@ -62,14 +60,14 @@ public class ToolArgumentValidator { * 담당합니다. */ private void validateStableContract(ToolCall call, JsonNode schema) { - if (schema.has("type") && !"object".equals(schema.path("type").asString())) { + if (schema.has("type") && !"object".equals(schema.path("type").asText())) { throw invalid("Only object inputSchema is supported by this adapter"); } JsonNode required = schema.path("required"); if (required.isArray()) { required.forEach( field -> { - String name = field.asString(); + String name = field.asText(); if (!call.arguments().has(name) || call.arguments().get(name).isNull()) { throw invalid("'" + name + "' is required"); } @@ -84,7 +82,7 @@ public class ToolArgumentValidator { JsonNode value = call.arguments().get(entry.getKey()); if (value != null && !value.isNull()) { validateStableType( - entry.getKey(), entry.getValue().path("type").asString(null), value); + entry.getKey(), entry.getValue().path("type").asText(null), value); } }); } @@ -99,7 +97,7 @@ public class ToolArgumentValidator { } boolean valid = switch (type) { - case "string" -> value.isString(); + case "string" -> value.isTextual(); case "integer" -> value.isIntegralNumber(); case "number" -> value.isNumber(); case "boolean" -> value.isBoolean(); diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java index 6fd5926..5143d0d 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java @@ -1,6 +1,6 @@ package io.shinhanlife.dap.biz.mcp.execute; -import tools.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonNode; /** * MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java index 799d194..7446af2 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java @@ -1,5 +1,6 @@ package io.shinhanlife.dap.biz.mcp.execute; +import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; @@ -10,8 +11,10 @@ import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.springframework.stereotype.Service; -import tools.jackson.databind.JsonNode; /** * MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry, @@ -20,11 +23,14 @@ import tools.jackson.databind.JsonNode; @Service public class ToolExecutionService { + private static final long STALE_REFRESH_COOLDOWN_NANOS = Duration.ofSeconds(5).toNanos(); + private final ToolRegistryService registryService; private final ToolArgumentValidator argumentValidator; private final ToolRoutingService routingService; private final ToolClient toolClient; private final TraceLogger traceLogger; + private final ConcurrentMap staleRefreshAttemptsByRoute = new ConcurrentHashMap<>(); /** * metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다. @@ -47,7 +53,7 @@ public class ToolExecutionService { * payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다. */ public Result execute(ToolCall call, McpRequestContext context) { - ToolMetadata metadata = registryService.findEnabledTool(call.toolName()); + ToolMetadata metadata = registryService.findEnabledTool(context.routeKey(), call.toolName()); argumentValidator.validate(call, metadata); ToolRequest toolRequest = routingService.route(call, metadata); traceLogger.event( @@ -71,10 +77,63 @@ public class ToolExecutionService { return new Result(response.data(), duration); } catch (ToolClientException exception) { traceLogger.error("tool_http_request_failed", exception, "toolName", toolRequest.toolName()); + refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception); throw mapException(exception, toolRequest); } } + /** + * Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다. + * 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다. + * route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다. + */ + private void refreshRouteOnStaleToolSignal(String routeKey, ToolRequest request, ToolClientException exception) { + if (!isStaleToolSignal(exception) || !claimStaleRefreshSlot(routeKey)) { + return; + } + try { + registryService.refresh(routeKey); + traceLogger.event( + "tool_registry_refresh_triggered_by_stale_tool", + "routeKey", + routeKey, + "toolName", + request.toolName()); + } catch (RuntimeException refreshFailure) { + traceLogger.error( + "tool_registry_refresh_after_stale_tool_failed", + refreshFailure, + "routeKey", + routeKey, + "toolName", + request.toolName()); + } + } + + /** + * upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다. + * 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다. + */ + private boolean isStaleToolSignal(ToolClientException exception) { + java.util.OptionalInt status = exception.httpStatusCode(); + return status.isPresent() && (status.getAsInt() == 404 || status.getAsInt() == 410); + } + + /** + * 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다. + * 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다. + */ + private boolean claimStaleRefreshSlot(String routeKey) { + String key = routeKey == null ? "" : routeKey; + long now = System.nanoTime(); + Long previous = staleRefreshAttemptsByRoute.get(key); + if (previous != null && now - previous < STALE_REFRESH_COOLDOWN_NANOS) { + return false; + } + staleRefreshAttemptsByRoute.put(key, now); + return true; + } + /** * System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다. */ 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 332ed25..463a05a 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 @@ -39,7 +39,9 @@ public class ToolRoutingService { JsonRpcErrorCode.INVALID_PARAMS, "params.name must contain 1-64 letters, digits, underscore, hyphen, dot, or slash"); } - endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name(); + if (!metadata.exactEndpoint()) { + endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name(); + } return new ToolRequest( metadata.name(), metadata.version(), diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java index f24a109..e20decb 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java @@ -1,6 +1,6 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc; -import tools.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonNode; /** * 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. transport, registry, execute 계층이 이 예외를 발생시키고, {@code McpController} 또는 diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java new file mode 100644 index 0000000..8ae7a56 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java @@ -0,0 +1,24 @@ +package io.shinhanlife.dap.biz.mcp.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다. + * 일반 request handler가 즉시 HTTP 응답으로 반환하는 객체가 아니라 Registry refresh 같은 배경 처리 단계에서 생성되며, + * 실제 전송은 SSE/Streamable HTTP 같은 transport 확장 지점이 담당합니다. 주요 의존성은 JSON-RPC version 상수와 + * notification method 계약입니다. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record JsonRpcNotification(String jsonrpc, String method, Object params) { + + public static final String METHOD_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; + + /** + * Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다. + * notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다. + */ + public static JsonRpcNotification toolsListChanged() { + return new JsonRpcNotification(McpSchema.JSONRPC_VERSION, METHOD_TOOLS_LIST_CHANGED, null); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java index 4a8e99f..64df65a 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java @@ -1,6 +1,6 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc; -import tools.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonNode; /** * 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증 diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java index 67706c1..a411a45 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java @@ -1,10 +1,10 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.modelcontextprotocol.spec.McpSchema; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.node.JsonNodeFactory; /** * HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. 설정된 MCP POST endpoint의 모든 요청이 이 클래스를 지나며 여기서 JSON 구조를 검사합니다. HTTP 경계 로그는 filter가 @@ -19,7 +19,7 @@ public class JsonRpcRequestParser { public JsonRpcRequest parse(JsonNode envelope) { try { validate(envelope); - String method = envelope.get("method").asString(); + String method = envelope.get("method").asText(); JsonNode params = envelope.hasNonNull("params") ? envelope.get("params") @@ -39,10 +39,10 @@ public class JsonRpcRequestParser { throw new JsonRpcException( JsonRpcErrorCode.INVALID_REQUEST, "JSON-RPC envelope must be an object"); } - if (!McpSchema.JSONRPC_VERSION.equals(envelope.path("jsonrpc").asString(null))) { + if (!McpSchema.JSONRPC_VERSION.equals(envelope.path("jsonrpc").asText(null))) { throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "jsonrpc must be exactly '2.0'"); } - String method = envelope.path("method").asString(null); + String method = envelope.path("method").asText(null); if (!StringUtils.hasText(method)) { throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "method is required"); } @@ -52,7 +52,7 @@ public class JsonRpcRequestParser { } if (envelope.has("id") && !envelope.get("id").isNull() - && !envelope.get("id").isString() + && !envelope.get("id").isTextual() && !envelope.get("id").isNumber()) { throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "id must be a string or number"); } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java index 7a01a9a..a5c7525 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java @@ -1,14 +1,12 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; - import java.util.LinkedHashMap; import java.util.Map; -import tools.jackson.databind.JsonNode; - /** * MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. {@code McpController}와 {@code McpExceptionHandler}가 설정된 MCP endpoint의 응답 본문으로 사용하며, 성공과 * 오류를 동시에 넣지 않습니다. 주요 의존성은 request ID correlation을 위한 {@link JsonNode}와 null 필드를 제외하는 Jackson 직렬화 설정입니다. diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java index 9d47530..21b3838 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java @@ -36,12 +36,20 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { + String routeKey = context == null || context.routeKey() == null || context.routeKey().isBlank() + ? null : context.routeKey().trim().toLowerCase(java.util.Locale.ROOT); + String serverName = routeKey == null + ? properties.server().name() + : properties.server().name() + "-" + routeKey; + String serverTitle = routeKey == null + ? properties.server().title() + : properties.server().title() + " (" + routeKey.toUpperCase(java.util.Locale.ROOT) + ")"; McpSchema.Implementation serverInfo = - McpSchema.Implementation.builder(properties.server().name(), properties.server().version()) - .title(properties.server().title()) + McpSchema.Implementation.builder(serverName, properties.server().version()) + .title(serverTitle) .build(); McpSchema.ServerCapabilities capabilities = - McpSchema.ServerCapabilities.builder().tools(false).build(); + McpSchema.ServerCapabilities.builder().tools(true).build(); McpSchema.InitializeResult result = McpSchema.InitializeResult.builder( properties.protocol().preferredVersion(), capabilities, serverInfo) diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java index ed41129..ce057d6 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java @@ -1,5 +1,6 @@ package io.shinhanlife.dap.biz.mcp.method; +import com.fasterxml.jackson.databind.JsonNode; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.execute.ToolCall; @@ -8,13 +9,10 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; - import java.util.List; import java.util.Map; - import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import tools.jackson.databind.JsonNode; /** * MCP {@code tools/call} 요청을 받아 Tool 실행 계층으로 전달하고 MCP result 형식으로 되돌리는 method handler입니다. {@link ToolExecutionService}를 통해 Tool을 실행하고 결과는 MCP SDK의 @@ -61,7 +59,7 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { * 표준 tools/call params에서 Tool 이름과 object arguments를 검증해 내부 호출 값으로 만듭니다. */ private ToolCall extract(JsonRpcRequest request) { - String toolName = request.params().path("name").asString(null); + String toolName = request.params().path("name").asText(null); JsonNode arguments = request.params().get("arguments"); if (!StringUtils.hasText(toolName)) { throw invalid(request, "params.name is required"); @@ -97,7 +95,7 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { if (data == null || data.isNull()) { return ""; } - return data.isString() ? data.asString() : data.toString(); + return data.isTextual() ? data.asText() : data.toString(); } /** diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java index 5b79e2d..0226514 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java @@ -1,18 +1,16 @@ package io.shinhanlife.dap.biz.mcp.method; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; - import java.util.List; - import org.springframework.stereotype.Component; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.node.ObjectNode; /** * MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. 내부 Tool Registry의 활성 metadata를 읽어 MCP SDK의 표준 {@link McpSchema.Tool}과 @@ -46,7 +44,7 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { - List tools = registryService.listTools().stream().map(this::toMcpTool).toList(); + List tools = registryService.listTools(context.routeKey()).stream().map(this::toMcpTool).toList(); return JsonRpcResponse.success(request.id(), McpSchema.ListToolsResult.builder(tools).build()); } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java index 59faeb8..481e05d 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java @@ -2,8 +2,8 @@ package io.shinhanlife.dap.biz.mcp.observability; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; -import org.springframework.boot.health.contributor.Health; -import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; /** diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java index 4acd178..4831561 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -1,21 +1,19 @@ package io.shinhanlife.dap.biz.mcp.registry; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; - import java.io.IOException; import java.util.ArrayList; import java.util.List; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Profile; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.node.ObjectNode; /** * 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. {@code tools/list}와 local @@ -24,11 +22,7 @@ import tools.jackson.databind.node.ObjectNode; */ @Component @Profile("local") -@ConditionalOnProperty( - prefix = "mcp.discovery", - name = "enabled", - havingValue = "false", - matchIfMissing = true) +@ConditionalOnExpression("!${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") public class LocalFileToolRegistryClient implements ToolRegistryClient { private final ResourceLoader resourceLoader; @@ -49,7 +43,7 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { * local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다. */ @Override - public List fetchTools() { + public List fetchTools(String routeKey) { String location = properties.registry().localToolFile(); Resource resource = resourceLoader.getResource(location); try (var inputStream = resource.getInputStream()) { @@ -88,14 +82,14 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { JsonNode meta = tool.path("_meta"); String name = requiredText(tool, "name", location); String endpoint = requiredText(meta, "endpoint", location); - String version = meta.path("version").asString("local"); + String version = meta.path("version").asText("local"); int timeoutMillis = meta.path("timeoutMillis").asInt(properties.toolClient().readTimeoutMillis()); boolean enabled = meta.path("enabled").asBoolean(true); return new ToolMetadata( name, version, - tool.path("description").asString(""), + tool.path("description").asText(""), endpoint, tool.get("inputSchema"), timeoutMillis, @@ -116,7 +110,7 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { * local sample의 필수 문자열 field를 검증하고 누락 시 Registry unavailable 오류로 바꿉니다. */ private String requiredText(JsonNode source, String fieldName, String location) { - String value = source.path(fieldName).asString(null); + String value = source.path(fieldName).asText(null); if (value == null || value.isBlank()) { throw unavailable(location, "Local Tool catalog is missing " + fieldName, null); } 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 new file mode 100644 index 0000000..1b416e0 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java @@ -0,0 +1,332 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import com.fasterxml.jackson.databind.JsonNode; +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; +import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleResult; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +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.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * Portal Registry API를 Tool Server endpoint 원천으로 사용하는 adapter입니다. + * MCP 요청을 직접 처리하지 않고 {@link ToolRegistryService}의 기동 preload와 주기 refresh에서 호출되며, 포털 응답을 기존 {@link ToolBundleDiscovery} 검증 경로로 연결합니다. + * 주요 의존성은 포털 조회용 {@link RestClient}, Tool Service manifest 검증을 담당하는 {@link ToolBundleDiscovery}, 그리고 portal/discovery 정책을 제공하는 {@link McpProperties}입니다. + */ +@Component +@ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "true") +public class PortalToolRegistryClient implements ToolRegistryClient { + + private static final Logger log = LoggerFactory.getLogger(PortalToolRegistryClient.class); + + private final RestClient restClient; + private final McpProperties properties; + private final ToolBundleDiscovery discovery; + private final Optional redisPortalRegistryCache; + private final java.util.concurrent.atomic.AtomicReference lastPortalRevision = + new java.util.concurrent.atomic.AtomicReference<>(); + private final java.util.concurrent.ConcurrentMap> bundlesByRoute = + new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Portal Registry 조회 client와 기존 Tool Service manifest discovery를 주입받습니다. + * 포털 응답은 이 adapter에서만 실행 주소 정보로 변환하고, 실제 manifest 검증은 기존 discovery 계약을 재사용합니다. + */ + public PortalToolRegistryClient( + @Qualifier("manifestRestClient") RestClient restClient, + McpProperties properties, + ToolBundleDiscovery discovery, + Optional redisPortalRegistryCache) { + this.restClient = restClient; + this.properties = properties; + this.discovery = discovery; + this.redisPortalRegistryCache = redisPortalRegistryCache; + } + + /** + * 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다. + * 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다. + */ + @Override + public List fetchTools(String routeKey) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + ensurePortalRegistryLoaded(); + List bundles = bundlesByRoute.get(normalizedRouteKey); + if (bundles == null) { + throw unavailable("Portal registry route is not found: " + normalizedRouteKey); + } + return fetchRouteTools(normalizedRouteKey, bundles); + } + + /** + * 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. + * 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다. + */ + @Override + public Map> fetchAllTools() { + ensurePortalRegistryLoaded(); + Map> snapshots = new LinkedHashMap<>(); + bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles))); + return Map.copyOf(snapshots); + } + + /** + * 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다. + * manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다. + */ + @Override + public boolean refreshSourceRegistry() { + String registryUrl = registryUrl(""); + JsonNode registry = loadPortalRegistryWithFallback(registryUrl); + return registerPortalRegistry(registryUrl, registry); + } + + /** + * Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다. + * 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다. + */ + private JsonNode loadPortalRegistryWithFallback(String registryUrl) { + try { + return portalRegistry(registryUrl); + } catch (RuntimeException exception) { + if (!bundlesByRoute.isEmpty()) { + log.warn( + "Portal registry refresh failed; keeping in-memory endpoint snapshot. reason={}", + exception.getClass().getSimpleName()); + return null; + } + Optional cached = redisPortalRegistryCache.flatMap(RedisPortalRegistryCache::loadRegistry); + if (cached.isPresent()) { + log.info("Portal registry loaded from Redis fallback. key={}", + redisPortalRegistryCache.map(RedisPortalRegistryCache::key).orElse("")); + return cached.get(); + } + throw exception; + } + } + + /** + * Portal 또는 Redis에서 읽은 registry JSON을 route별 endpoint memory snapshot으로 반영합니다. + * route key는 포털 응답 안에 반드시 있어야 하며, 설정 기본 route로 보정하지 않습니다. + */ + private boolean registerPortalRegistry(String registryUrl, JsonNode registry) { + if (registry == null) { + return false; + } + boolean changed = logPortalRegistryIfChanged(registryUrl, registry); + JsonNode routes = registry.path("routes"); + if (!routes.isArray()) { + String routeKey = normalizeRouteKey(required(registry, "routeKey")); + bundlesByRoute.put(routeKey, toBundles(registry.path("toolServices"))); + return changed; + } + Map> updated = new LinkedHashMap<>(); + for (JsonNode route : routes) { + String routeKey = normalizeRouteKey(required(route, "routeKey")); + updated.put(routeKey, toBundles(route.path("toolServices"))); + } + bundlesByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey)); + bundlesByRoute.putAll(updated); + return changed; + } + + /** + * 포털 registry URL을 호출하고 기본 응답 shape를 검증합니다. + * 원문 payload를 오류 메시지에 포함하지 않고, 호출 실패는 Registry unavailable 예외로 상위 refresh 정책에 전달합니다. + */ + private JsonNode portalRegistry(String registryUrl) { + JsonNode registry = restClient.get() + .uri(registryUrl) + .retrieve() + .body(JsonNode.class); + if (registry == null) { + throw unavailable("Portal registry response is invalid"); + } + return registry; + } + + /** + * 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다. + * 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다. + */ + private boolean logPortalRegistryIfChanged(String registryUrl, JsonNode registry) { + String revision = registry.path("registryRevision").asText(""); + String previous = lastPortalRevision.get(); + boolean changed = previous == null || !previous.equals(revision); + if (changed && lastPortalRevision.compareAndSet(previous, revision)) { + log.info( + "Portal registry response accepted. registryUrl={} previousRevision={} registryRevision={} body={}", + registryUrl, + previous, + revision, + registry.toPrettyString()); + return true; + } + return false; + } + + /** + * 단일 route의 endpoint 목록을 route별 Tool metadata snapshot으로 변환합니다. + * 각 Tool Service manifest 조회 결과는 기존 discovery 검증과 merge 규칙을 통과해야 합니다. + */ + private List fetchRouteTools(String routeKey, List bundles) { + List results = discovery.discoverAll(bundles); + return merge(results); + } + + /** + * 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다. + * 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다. + */ + private void ensurePortalRegistryLoaded() { + if (bundlesByRoute.isEmpty()) { + refreshSourceRegistry(); + } + } + + /** + * 에이전트 요청 경로나 포털 응답에서 받은 route key를 메모리 snapshot 조회 key로 정규화합니다. + * route key가 비어 있으면 기본 route로 보정하지 않고 registry unavailable 오류로 처리해 잘못된 단일 진입점 호출을 드러냅니다. + */ + private String normalizeRouteKey(String routeKey) { + if (routeKey == null || routeKey.isBlank()) { + throw unavailable("Portal registry routeKey is required"); + } + return routeKey.trim(); + } + + /** + * 포털 Registry URL을 호출 주소로 변환합니다. + * URL에 {@code {route}} placeholder가 있으면 치환하고, 없으면 전체 registry 조회 URL로 그대로 사용합니다. + */ + private String registryUrl(String routeKey) { + String configured = properties.portal().registryUrl(); + return configured.contains("{route}") ? configured.replace("{route}", routeKey) : configured; + } + + /** + * 포털의 active Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다. + * service domain, manifest path, 실행 base path를 안정적인 URL 조합으로 정규화하며 active 서비스가 없으면 갱신을 거부합니다. + */ + private List toBundles(JsonNode services) { + List bundles = new ArrayList<>(); + for (JsonNode service : services) { + if (!"ACTIVE".equalsIgnoreCase(service.path("status").asText("ACTIVE"))) { + continue; + } + 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), + service.path("namePrefix").asText(""), + true, + null, + toolEndpoints)); + } + if (bundles.isEmpty()) { + throw unavailable("Portal registry has no active Tool Service"); + } + return List.copyOf(bundles); + } + + /** + * Tool Service별 discovery 결과를 하나의 MCP Tool catalog로 병합합니다. + * 사용 가능한 성공본이 없는 서비스, Tool name 중복, 전체 상한 초과는 불완전한 snapshot을 만들지 않도록 실패 처리합니다. + */ + private List merge(List results) { + if (results.stream().anyMatch(result -> !result.usableSnapshot())) { + throw unavailable("At least one Portal Tool Service has no usable snapshot"); + } + List candidates = new ArrayList<>(); + for (BundleResult result : results) { + result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool))); + } + candidates.sort(Comparator.comparing(BundleTool::bundleId).thenComparing(entry -> entry.tool().name())); + + int maxTotal = properties.discovery().maxToolsTotal(); + Set names = new HashSet<>(); + List merged = new ArrayList<>(); + for (BundleTool candidate : candidates) { + if (!names.add(candidate.tool().name())) { + throw unavailable("Duplicate Tool name across Portal services: " + candidate.tool().name()); + } + if (merged.size() >= maxTotal) { + throw unavailable("Tool catalog exceeds maxToolsTotal: " + maxTotal); + } + merged.add(candidate.tool()); + } + return List.copyOf(merged); + } + + /** + * 포털 응답의 필수 문자열 필드를 읽고 누락 시 registry 구성 오류로 변환합니다. + * 원문 payload를 오류 메시지에 포함하지 않아 포털 응답의 민감 정보가 로그로 노출되지 않게 합니다. + */ + private String required(JsonNode node, String field) { + String value = node.path(field).asText(null); + if (value == null || value.isBlank()) { + throw unavailable("Portal Tool Service field is required: " + field); + } + return value; + } + + /** + * 앞에 slash가 붙은 manifest path를 service domain 뒤에 붙일 수 있는 내부 경로 형태로 정규화합니다. + */ + private String normalizePath(String path) { + 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 결과를 안정화합니다. + */ + private String trimTrailingSlash(String value) { + return value.replaceAll("/+$", ""); + } + + /** + * registry 원천 오류를 표준 JSON-RPC registry unavailable 예외로 변환합니다. + */ + private JsonRpcException unavailable(String message) { + return new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, message); + } + + /** + * 병합 정렬 중 bundle id와 Tool metadata를 함께 보관하는 내부 값 객체입니다. + */ + private record BundleTool(String bundleId, ToolMetadata tool) { + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java new file mode 100644 index 0000000..53de90e --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java @@ -0,0 +1,62 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback adapter입니다. + * MCP 요청을 직접 처리하지 않고 background preload/refresh 단계에서만 사용되며, Redis 값은 포털 DB/API의 + * 보조 복제본으로 취급합니다. 주요 외부 경계는 포털이 기록하는 Redis key와 JSON 구조입니다. + */ +@Component +@ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") +public class RedisPortalRegistryCache { + + private static final Logger log = LoggerFactory.getLogger(RedisPortalRegistryCache.class); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final String cacheKey; + + /** + * Redis 접근 객체와 JSON mapper, MCP 설정에서 포털 registry fallback key를 구성합니다. + * Redis가 꺼져 있으면 Spring 조건에 의해 생성되지 않으며, key 값은 운영에서 포털과 합의한 값으로 덮어씁니다. + */ + public RedisPortalRegistryCache( + StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.cacheKey = properties.redis().portalRegistryKey(); + } + + /** + * 포털이 Redis에 저장한 aggregate registry JSON을 읽습니다. + * key miss, Redis 장애, JSON 파싱 오류는 모두 cache miss로 처리해 포털 API나 memory snapshot의 정상 동작을 막지 않습니다. + */ + public Optional loadRegistry() { + try { + String json = redisTemplate.opsForValue().get(cacheKey); + if (json == null || json.isBlank()) { + return Optional.empty(); + } + return Optional.of(objectMapper.readTree(json)); + } catch (Exception exception) { + log.warn("Redis Portal registry cache read failed: {}", exception.getClass().getSimpleName()); + return Optional.empty(); + } + } + + /** + * 운영 진단과 테스트에서 포털과 합의한 Redis key를 확인할 수 있게 반환합니다. + */ + public String key() { + return cacheKey; + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java index 847cab9..0215579 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java @@ -1,92 +1,138 @@ package io.shinhanlife.dap.biz.mcp.registry; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.biz.mcp.config.McpProperties; - +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; import java.util.List; import java.util.Optional; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import tools.jackson.core.type.TypeReference; -import tools.jackson.databind.ObjectMapper; /** - * MCP replica 사이에서 Tool snapshot을 공유하는 선택적 Redis cache adapter입니다. 원천이 아니라 공유 지점이므로 조회 성공 결과만 저장하고, 읽기·쓰기·직렬화 실패는 모두 cache miss로 처리합니다. - * {@code tools/list} 요청 경로에서는 호출하지 않으며 {@link ToolRegistryService}의 배경 갱신과 warm start에서만 사용합니다. 주요 의존성은 RedisTemplate, ObjectMapper와 {@link McpProperties}입니다. + * MCP replica 사이에서 Tool snapshot을 route별로 공유하는 선택적 Redis cache adapter입니다. + * 원천이 아니라 공유 지점이므로 조회 성공 결과만 저장하고 읽기, 쓰기, 직렬화 실패는 모두 cache miss로 격리합니다. + * {@code tools/list} 요청 경로에서는 호출하지 않으며 {@link ToolRegistryService}의 배경 갱신과 warm start에서만 사용합니다. */ @Component @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") public class RedisToolRegistryCache { /** - * 캐시에 저장하는 JSON 구조의 버전입니다. 구조가 바뀌면 이 값을 올려 서로 다른 버전의 MCP가 같은 key를 읽어 오염되는 것을 막습니다. + * route별 key 구조를 포함하는 Tool snapshot cache schema version입니다. + * 기존 단일 {@code :all} key와 섞이지 않도록 version을 올려 서로 다른 route의 Tool 목록이 같은 key를 공유하지 않게 합니다. */ - static final String CACHE_SCHEMA_VERSION = "v1"; + static final String CACHE_SCHEMA_VERSION = "v2"; private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class); private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; - private final String cacheKey; + private final String cacheKeyPrefix; private final Duration ttl; /** - * Redis 접근, JSON 변환, key와 TTL 설정을 주입받아 공유 cache를 구성합니다. + * Redis 접근, JSON 변환, route별 key prefix와 TTL 설정을 주입받아 공유 cache를 구성합니다. + * 이 생성자는 외부 요청을 처리하지 않고, 이후 route별 load/save 호출에서 key를 완성합니다. */ public RedisToolRegistryCache( StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; - this.cacheKey = - "%s:%s:%s:all" + this.cacheKeyPrefix = + "%s:%s:%s:route" .formatted(properties.redis().keyPrefix(), properties.identity(), CACHE_SCHEMA_VERSION); this.ttl = Duration.ofSeconds(Math.max(30, properties.registry().refreshIntervalSeconds() * 3)); } /** - * 이 MCP 인스턴스가 사용하는 Redis key를 반환합니다. 운영 진단과 테스트에서 key 규칙을 확인할 때 사용합니다. + * 기존 단일 route 호출부와 테스트가 사용하는 기본 route Redis key를 반환합니다. + * 실제 route별 진단에는 {@link #key(String)}를 사용합니다. */ public String key() { - return cacheKey; + return key(""); } /** - * 다른 replica가 저장한 Tool snapshot을 읽습니다. key miss, Redis 장애와 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 결과로 진행하게 합니다. + * 지정 route가 사용하는 Redis key를 반환합니다. + * route 원문은 key 구분자와 충돌하지 않도록 URL-safe Base64 token으로 변환합니다. + */ + public String key(String routeKey) { + return cacheKeyPrefix + ":" + routeToken(routeKey); + } + + /** + * 기본 route의 Tool snapshot을 읽습니다. + * route별 호출부는 {@link #loadSnapshot(String)}를 사용해 다른 route와 cache가 섞이지 않게 합니다. */ public Optional> loadSnapshot() { + return loadSnapshot(""); + } + + /** + * 지정 route의 Tool snapshot을 읽습니다. + * key miss, Redis 장애, 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 refresh 정책으로 진행하게 합니다. + */ + public Optional> loadSnapshot(String routeKey) { try { - String json = redisTemplate.opsForValue().get(cacheKey); + String json = redisTemplate.opsForValue().get(key(routeKey)); if (json == null) { return Optional.empty(); } return Optional.of(objectMapper.readValue(json, new TypeReference<>() { })); } catch (Exception exception) { - logFailure("read", exception); + logFailure("read", routeKey, exception); return Optional.empty(); } } /** - * 원천 조회에 성공한 snapshot만 공유 지점에 저장하고 TTL을 설정합니다. 실패한 조회 결과를 저장하면 다른 replica가 구해 온 정상 snapshot을 덮어쓰므로 호출자가 성공 시에만 호출해야 합니다. 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신을 - * 실패시키지 않습니다. + * 기본 route의 Tool snapshot을 저장합니다. + * route별 저장은 {@link #saveSnapshot(String, List)}를 사용합니다. */ public void saveSnapshot(List tools) { + saveSnapshot("", tools); + } + + /** + * 지정 route의 Tool snapshot을 공유 지점에 저장하고 TTL을 설정합니다. + * 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신 성공 여부를 바꾸지 않습니다. + */ + public void saveSnapshot(String routeKey, List tools) { try { - redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(tools), ttl); + redisTemplate.opsForValue().set(key(routeKey), objectMapper.writeValueAsString(tools), ttl); } catch (Exception exception) { - logFailure("write", exception); + logFailure("write", routeKey, exception); } } /** - * payload와 credential을 남기지 않고 Redis 실패 작업과 예외 타입만 기록합니다. + * Redis key에 넣을 route token을 만듭니다. + * 빈 route는 기존 단일 route와 호환되는 고정 token으로 두고, 나머지는 URL-safe Base64로 구분자 충돌을 피합니다. */ - private void logFailure(String operation, Exception exception) { - logger.warn("Redis Tool cache {} failed: {}", operation, exception.getClass().getSimpleName()); + private String routeToken(String routeKey) { + if (routeKey == null || routeKey.isBlank()) { + return "_default"; + } + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(routeKey.trim().getBytes(StandardCharsets.UTF_8)); + } + + /** + * payload와 credential은 남기지 않고 Redis 실패 작업, route, 예외 타입만 기록합니다. + */ + private void logFailure(String operation, String routeKey, Exception exception) { + logger.warn( + "Redis Tool cache {} failed: routeKey={}, reason={}", + operation, + routeKey, + exception.getClass().getSimpleName()); } } 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 fd4d6f3..c92a721 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 @@ -1,8 +1,10 @@ package io.shinhanlife.dap.biz.mcp.registry; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; 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.nio.charset.StandardCharsets; @@ -18,19 +20,15 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; 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.ConditionalOnProperty; +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; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.node.ObjectNode; /** * 설정에 선언된 Tool Service bundle의 매니페스트를 동시에 조회·검증하고 bundle별 상태를 보관하는 discovery 구성요소입니다. MCP 요청을 직접 처리하지 않으며 {@link ToolBundleRegistryClient}의 배경 갱신에서만 호출됩니다. 개별 @@ -38,7 +36,7 @@ import tools.jackson.databind.node.ObjectNode; * 전용 RestClient, JSON mapper, ResourceLoader, {@link McpProperties}의 bundle·discovery 설정입니다. */ @Component -@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +@ConditionalOnExpression("${mcp.discovery.enabled:false} || ${mcp.portal.enabled:false}") public class ToolBundleDiscovery { private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class); @@ -67,7 +65,14 @@ public class ToolBundleDiscovery { * 활성 bundle 전체를 동시에 조회해 bundle별 결과를 반환합니다. 순차 조회는 소요 시간이 합산되어 기동과 갱신을 지연시키므로 virtual thread로 병렬 조회하며, 각 작업이 자기 예외를 결과값으로 변환하므로 이 method는 예외를 던지지 않습니다. */ public List discoverAll() { - List targets = properties.enabledBundles(); + return discoverAll(properties.enabledBundles()); + } + + /** + * 전달받은 Tool Service bundle 목록을 동시에 조회하고 bundle별 성공본 또는 last-good 결과를 반환합니다. + * Portal Registry client가 포털 응답을 임시 bundle 모델로 변환한 뒤 이 메서드를 호출합니다. + */ + public List discoverAll(List targets) { if (targets.isEmpty()) { return List.of(); } @@ -161,8 +166,13 @@ public class ToolBundleDiscovery { if (body == null || body.isBlank()) { throw new IllegalStateException("empty manifest body"); } - JsonNode manifest = objectMapper.readTree(body); - String declaredId = manifest.path("bundleId").asString(null); + JsonNode manifest; + try { + manifest = objectMapper.readTree(body); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + throw new IllegalStateException("manifest is not valid JSON", exception); + } + String declaredId = manifest.path("bundleId").asText(null); if (!bundle.id().equals(declaredId)) { throw new IllegalStateException("manifest bundleId does not match configuration"); } @@ -182,7 +192,7 @@ public class ToolBundleDiscovery { } metadata.add(converted); } - return new Manifest(manifest.path("revision").asString(null), List.copyOf(metadata)); + return new Manifest(manifest.path("revision").asText(null), List.copyOf(metadata)); } /** @@ -239,7 +249,7 @@ public class ToolBundleDiscovery { * 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. */ private ToolMetadata toToolMetadata(Bundle bundle, JsonNode tool) { - String name = tool.path("name").asString(null); + String name = tool.path("name").asText(null); if (name == null || !TOOL_NAME.matcher(name).matches()) { throw new IllegalStateException("Tool name must match [A-Za-z0-9_./-]{1,64}"); } @@ -247,7 +257,7 @@ public class ToolBundleDiscovery { if (prefix != null && !prefix.isBlank() && !name.startsWith(prefix)) { throw new IllegalStateException("Tool name does not start with the bundle namePrefix"); } - String description = tool.path("description").asString(null); + String description = tool.path("description").asText(null); if (description == null || description.isBlank()) { throw new IllegalStateException("Tool description is required"); } @@ -256,19 +266,26 @@ public class ToolBundleDiscovery { throw new IllegalStateException("Tool inputSchema must be a JSON Schema object"); } JsonNode meta = tool.path("_meta"); - String version = meta.path("version").asString(null); + String version = meta.path("version").asText(null); 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("^/+", ""); + } return new ToolMetadata( name, version, description, - bundle.baseEndpoint().replaceAll("/+$", ""), + endpoint, inputSchema, clampTimeout(meta), meta.path("enabled").asBoolean(true), - publicDefinition(tool)); + publicDefinition(tool), + exactEndpoint); } /** diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java index 7f25e1a..ceb116e 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java @@ -11,7 +11,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; /** @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; * 설정입니다. */ @Component -@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true") +@ConditionalOnExpression("${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") public class ToolBundleRegistryClient implements ToolRegistryClient { private final ToolBundleDiscovery discovery; @@ -39,7 +39,7 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { * {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. */ @Override - public List fetchTools() { + public List fetchTools(String routeKey) { List results = discovery.discoverAll(); if (results.stream().anyMatch(result -> !result.usableSnapshot())) { throw new JsonRpcException( diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java new file mode 100644 index 0000000..26fa990 --- /dev/null +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java @@ -0,0 +1,26 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcNotification; + +/** + * route별 Tool catalog snapshot이 실제로 변경됐음을 transport 계층에 전달하는 내부 도메인 이벤트입니다. + * Registry refresh 배경 처리에서 발행되며 직접 Agent Builder 요청을 처리하지 않습니다. 이벤트 소비자는 route별 연결 상태를 + * 알고 있는 SSE/Streamable HTTP 전송 계층이며, payload는 Agent Builder로 보낼 표준 JSON-RPC notification입니다. + */ +public record ToolListChangedEvent(String routeKey, JsonRpcNotification notification) { + + /** + * 변경된 route key와 표준 {@code notifications/tools/list_changed} envelope를 묶습니다. + * route key는 전송 계층이 같은 route로 initialize한 Agent Builder 연결만 골라 알릴 때 사용합니다. + */ + public ToolListChangedEvent { + } + + /** + * route별 Tool 목록 변경 이벤트를 생성합니다. + * notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다. + */ + public static ToolListChangedEvent forRoute(String routeKey) { + return new ToolListChangedEvent(routeKey, JsonRpcNotification.toolsListChanged()); + } +} diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java index 68fbdb4..9f2db20 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java @@ -1,7 +1,7 @@ package io.shinhanlife.dap.biz.mcp.registry; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import tools.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonNode; /** * 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고, @@ -16,7 +16,20 @@ public record ToolMetadata( JsonNode inputSchema, Integer timeoutMillis, boolean enabled, - JsonNode publicDefinition) { + JsonNode publicDefinition, + boolean exactEndpoint) { + + public ToolMetadata( + String name, + String version, + String description, + String endpoint, + JsonNode inputSchema, + Integer timeoutMillis, + boolean enabled, + JsonNode publicDefinition) { + this(name, version, description, endpoint, inputSchema, timeoutMillis, enabled, publicDefinition, false); + } /** * Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다. 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 76bb8c5..3bf1578 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 @@ -1,6 +1,7 @@ package io.shinhanlife.dap.biz.mcp.registry; import java.util.List; +import java.util.Map; /** * Tool metadata의 원천(source)을 읽는 역할입니다. @@ -16,5 +17,28 @@ public interface ToolRegistryClient { /** * 현재 profile의 원천에서 Tool 전체 목록을 읽어 immutable 목록으로 반환합니다. */ - List fetchTools(); + List fetchTools(String routeKey); + + /** + * 원천이 route별 전체 Tool catalog를 한 번에 제공할 수 있으면 route key별 immutable 목록으로 반환합니다. + * 지원하지 않는 구현은 빈 Map을 반환하며, 호출자는 기존 단일 route 조회 방식으로 fallback합니다. + */ + default Map> fetchAllTools() { + return Map.of(); + } + + /** + * Tool metadata 조회에 앞서 외부 registry의 endpoint 목록을 갱신합니다. + * 포털을 사용하지 않는 구현은 아무 작업도 하지 않으며, 호출자는 실패 시 기존 snapshot을 유지합니다. + */ + default boolean refreshSourceRegistry() { + return false; + } + + /** + * route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다. + */ + default List fetchTools() { + return fetchTools(""); + } } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java index 5fdea0c..ae1a55b 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java @@ -34,7 +34,8 @@ public class ToolRegistryRefreshScheduler { @EventListener(ApplicationReadyEvent.class) public void preload() { safeWarmStart(); - safeRefresh("preload"); + safePortalRefresh("preload"); + safeManifestRefresh("preload"); firstAttemptCompleted = true; } @@ -68,22 +69,53 @@ public class ToolRegistryRefreshScheduler { + " + T(java.util.concurrent.ThreadLocalRandom).current()" + ".nextLong(0, ${mcp.registry.refresh-jitter-seconds:5} + 1)}", timeUnit = TimeUnit.SECONDS) - public void scheduledRefresh() { - safeRefresh("scheduled"); + public void scheduledManifestRefresh() { + safeManifestRefresh("scheduled"); + } + + /** + * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. + * manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. + */ + @Scheduled( + fixedDelayString = "${mcp.portal.refresh-interval-seconds:300}", + initialDelayString = "${mcp.portal.refresh-interval-seconds:300}", + timeUnit = TimeUnit.SECONDS) + public void scheduledPortalRefresh() { + if (safePortalRefresh("scheduled")) { + safeManifestRefresh("portal-change"); + } } /** * refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다. */ - private void safeRefresh(String trigger) { + private void safeManifestRefresh(String trigger) { try { - registryService.refresh(); + registryService.refreshKnownRoutes(); } catch (RuntimeException exception) { // Cache preload/refresh is best-effort; request-time direct lookup remains available. logger.warn( - "Tool Registry refresh failed: trigger={}, reason={}", + "Tool manifest refresh failed: trigger={}, reason={}, message={}", trigger, - exception.getClass().getSimpleName()); + exception.getClass().getSimpleName(), + exception.getMessage()); + } + } + + /** + * 포털 registry endpoint 목록 갱신 실패를 로그로 격리하여 manifest refresh와 요청 경로에 영향을 주지 않게 합니다. + */ + private boolean safePortalRefresh(String trigger) { + try { + return registryService.refreshSourceRegistry(); + } catch (RuntimeException exception) { + logger.warn( + "Portal registry refresh failed: trigger={}, reason={}, message={}", + trigger, + exception.getClass().getSimpleName(), + exception.getMessage()); + return false; } } } 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 297ab34..53fff88 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 @@ -1,75 +1,129 @@ package io.shinhanlife.dap.biz.mcp.registry; +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 java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.atomic.AtomicReference; - +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; 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입니다. + * 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 { + private static final Logger log = LoggerFactory.getLogger(ToolRegistryService.class); + private final ToolRegistryClient registryClient; private final Optional redisCache; - private final AtomicReference> snapshot = new AtomicReference<>(); - private final AtomicReference>> refreshInFlight = - new AtomicReference<>(); + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + private final ConcurrentMap> snapshotsByRoute = new ConcurrentHashMap<>(); + private final ConcurrentMap>> refreshInFlightByRoute = + new ConcurrentHashMap<>(); /** - * 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다. + * ?먯쿇 Registry?€ memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎. */ public ToolRegistryService( ToolRegistryClient registryClient, Optional redisCache) { - this.registryClient = registryClient; - this.redisCache = redisCache; + this(registryClient, redisCache, event -> { + }, new ObjectMapper()); } /** - * 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번 - * 조회해 cold start 공백을 메웁니다. + * ?먯쿇 Registry, ?좏깮??Redis 怨듭쑀 cache, Tool 紐⑸줉 蹂€寃??대깽??諛쒗뻾?먮? 二쇱엯諛쏆뒿?덈떎. + * Spring 湲곕룞 ???몄텧?섎ʼn, refresh ?깃났?쇰줈 湲곗〈 route snapshot???щ씪吏??뚮쭔 ?대깽?몃? 諛쒗뻾?⑸땲?? + */ + public ToolRegistryService( + ToolRegistryClient registryClient, + Optional redisCache, + ApplicationEventPublisher eventPublisher) { + this(registryClient, redisCache, eventPublisher, new ObjectMapper()); + } + + /** + * ?먯쿇 Registry, ?좏깮??Redis cache, 蹂€寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎. + * Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂€寃?寃€利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲?? + */ + @Autowired + public ToolRegistryService( + ToolRegistryClient registryClient, + Optional redisCache, + ApplicationEventPublisher eventPublisher, + ObjectMapper objectMapper) { + this.registryClient = registryClient; + this.redisCache = redisCache; + this.eventPublisher = eventPublisher; + this.objectMapper = objectMapper; + } + + /** + * ?쒖꽦 Tool 紐⑸줉??in-memory snapshot?먯꽌 ?쎌뒿?덈떎. ?붿껌 寃쎈줈?먯꽌??Redis瑜??몄텧?섏? ?딆쑝誘€濡?Redis ?μ븷??吏€?곗씠 {@code tools/list} ?묐떟 ?쒓컙???곹뼢??二쇱? ?딆뒿?덈떎. snapshot???꾩쭅 鍮꾩뼱 ?덈뒗 湲곕룞 吏곹썑?먮쭔 ?먯쿇????踰? * 議고쉶??cold start 怨듬갚??硫붿썎?덈떎. */ public List listTools() { - List memory = snapshot.get(); + return listTools(""); + } + + /** + * route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎. + * ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎. + */ + public List listTools(String routeKey) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + List memory = snapshotsByRoute.get(normalizedRouteKey); if (memory != null) { return memory; } - return refresh(); + return refresh(normalizedRouteKey); } /** - * 요청을 처리할 수 있는 Tool snapshot이 memory에 적재됐는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 빈 목록도 유효한 전체 상태이므로 {@code null} 여부만 판단하며, readiness 확인 과정에서 Redis나 Tool Service를 - * 호출하지 않습니다. + * ?붿껌??泥섎━?????덈뒗 Tool snapshot??memory???곸옱?먮뒗吏€ 諛섑솚?⑸땲?? ?먯쿇 ?먮뒗 Redis?먯꽌 ?깃났?곸쑝濡?梨꾪깮??鍮?紐⑸줉???좏슚???꾩껜 ?곹깭?대?濡?{@code null} ?щ?留??먮떒?섎ʼn, readiness ?뺤씤 怨쇱젙?먯꽌 Redis??Tool Service瑜? * ?몄텧?섏? ?딆뒿?덈떎. */ public boolean hasUsableSnapshot() { - return snapshot.get() != null; + return !snapshotsByRoute.isEmpty(); } /** - * 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다. + * 湲곕룞 吏곹썑 ?ㅻⅨ replica媛€ 怨듭쑀 吏€?먯뿉 ?€?ν빐 ??snapshot??癒쇱? ?곸옱?⑸땲?? 泥??먯쿇 議고쉶媛€ ?앸굹湲??꾩쓽 鍮?紐⑸줉 援ш컙??以꾩씠湲??꾪븳 best-effort ?숈옉?대ʼn, ?ㅽ뙣?섍굅??媛믪씠 ?놁쑝硫??꾨Т寃껊룄 ?섏? ?딆뒿?덈떎. */ public void warmStartFromSharedCache() { - if (snapshot.get() != null) { + if (!snapshotsByRoute.isEmpty()) { return; } redisCache - .flatMap(RedisToolRegistryCache::loadSnapshot) - .ifPresent(tools -> snapshot.compareAndSet(null, List.copyOf(tools))); + .flatMap(cache -> cache.loadSnapshot("")) + .ifPresent(tools -> snapshotsByRoute.putIfAbsent("", List.copyOf(tools))); } /** - * 표준 Tool 이름이 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다. + * ?쒖? Tool ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. cache媛€ ?ㅻ옒?먯쓣 ???덉쑝誘€濡?泥?議고쉶?먯꽌 紐?李얠쑝硫?Registry瑜???踰?refresh????理쒖쥌 ?먮떒?⑸땲?? */ public ToolMetadata findEnabledTool(String name) { - List cached = listTools(); + return findEnabledTool("", name); + } + + /** + * ?붿껌 route??Tool snapshot?먯꽌 ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. + * route蹂?cache媛€ ?ㅻ옒?섏뿀?????덉쑝誘€濡?理쒖큹 miss ???대떦 route留?refresh????理쒖쥌 ?먮떒?⑸땲?? + */ + public ToolMetadata findEnabledTool(String routeKey, String name) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + List cached = listTools(normalizedRouteKey); Optional match = match(cached, name); if (match.isPresent()) { return match.get(); @@ -77,7 +131,7 @@ public class ToolRegistryService { // A cache may be stale. Perform one direct lookup before declaring the tool missing. try { - List refreshed = refresh(); + List refreshed = refresh(normalizedRouteKey); return match(refreshed, name).orElseThrow(() -> notFound(name)); } catch (JsonRpcException exception) { if (exception.errorCode() == JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE @@ -89,47 +143,83 @@ public class ToolRegistryService { } /** - * Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를 - * 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다. + * Registry ?먯쿇??吏곸젒 ?쎌뼱 ?쒖꽦 Tool snapshot??媛깆떊?⑸땲?? 議고쉶???깃났?덉쓣 ?뚮쭔 snapshot??援먯껜?섍퀬 怨듭쑀 cache???€?ν븯誘€濡? ?ㅽ뙣媛€ 湲곗〈 紐⑸줉??鍮꾩슦嫄곕굹 ?ㅻⅨ replica媛€ ?€?ν븳 ?뺤긽 snapshot????뼱?곗? ?딆뒿?덈떎. memory瑜? * 癒쇱? 媛깆떊??Redis ?μ븷?€ 臾닿??섍쾶 理쒖떊 ?곹깭瑜??좎??⑸땲?? ?먯쿇 議고쉶媛€ ?ㅽ뙣?섎㈃ 湲곗〈 memory瑜??좎??섍퀬, memory媛€ 鍮꾩뼱 ?덉쓣 ?뚮쭔 怨듭쑀 cache瑜?梨꾪깮?⑸땲?? */ public List refresh() { + return refresh(""); + } + + /** + * 吏€?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲?? + * 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲?? + */ + public List refresh(String routeKey) { + String normalizedRouteKey = normalizeRouteKey(routeKey); CompletableFuture> candidate = new CompletableFuture<>(); CompletableFuture> running = - refreshInFlight.compareAndExchange(null, candidate); + refreshInFlightByRoute.putIfAbsent(normalizedRouteKey, candidate); if (running != null) { return awaitRefresh(running); } try { - List tools = refreshOnce(); + List tools = refreshOnce(normalizedRouteKey); candidate.complete(tools); return tools; } catch (RuntimeException exception) { candidate.completeExceptionally(exception); throw exception; } finally { - refreshInFlight.compareAndSet(candidate, null); + refreshInFlightByRoute.remove(normalizedRouteKey, candidate); } } /** - * Tool 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다. + * ?꾩옱 memory???뚮젮吏?紐⑤뱺 route瑜?二쇨린?곸쑝濡?媛깆떊?⑸땲?? + * ?꾩쭅 route ?붿껌???놁쑝硫?湲곗〈 湲곕낯 route留?媛깆떊??湲곗〈 ?⑥씪 route ?숈옉???좎??⑸땲?? */ - private List refreshOnce() { + public void refreshKnownRoutes() { + Map> snapshots = registryClient.fetchAllTools(); + if (!snapshots.isEmpty()) { + snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey)); + snapshots.forEach(this::replaceSnapshot); + return; + } + List routeKeys = snapshotsByRoute.isEmpty() + ? List.of("") + : List.copyOf(snapshotsByRoute.keySet()); + routeKeys.forEach(this::refresh); + } + + /** + * ?ы꽭泥섎읆 蹂꾨룄 registry瑜?媛€吏??먯쿇??endpoint 紐⑸줉留?媛깆떊?⑸땲?? + * Tool manifest 議고쉶?€ memory snapshot 援먯껜???섑뻾?섏? ?딆쑝硫? scheduler媛€ ?ы꽭 ?꾩슜 二쇨린?먯꽌 ?몄텧?⑸땲?? + */ + public boolean refreshSourceRegistry() { + return registryClient.refreshSourceRegistry(); + } + + /** + * Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory?€ Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛€ 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲?? + */ + private List refreshOnce(String routeKey) { try { List tools = - registryClient.fetchTools().stream().filter(ToolMetadata::enabled).toList(); - snapshot.set(List.copyOf(tools)); - redisCache.ifPresent(cache -> cache.saveSnapshot(tools)); + registryClient.fetchTools(routeKey).stream().filter(ToolMetadata::enabled).toList(); + List immutableTools = List.copyOf(tools); + List previous = snapshotsByRoute.put(routeKey, immutableTools); + logSnapshot(routeKey, previous, immutableTools); + publishListChangedIfNeeded(routeKey, previous, immutableTools); + redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, tools)); return tools; } catch (RuntimeException exception) { - List memory = snapshot.get(); + List memory = snapshotsByRoute.get(routeKey); if (memory != null) { return memory; } Optional> shared = - redisCache.flatMap(RedisToolRegistryCache::loadSnapshot); + redisCache.flatMap(cache -> cache.loadSnapshot(routeKey)); if (shared.isPresent()) { - snapshot.set(List.copyOf(shared.get())); + snapshotsByRoute.put(routeKey, List.copyOf(shared.get())); return shared.get(); } throw exception; @@ -137,8 +227,20 @@ public class ToolRegistryService { } /** - * 다른 호출이 시작한 refresh 결과를 기다리며 원래 RuntimeException 유형을 보존합니다. 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 사용합니다. + * ?ㅻⅨ ?몄텧???쒖옉??refresh 寃곌낵瑜?湲곕떎由щʼn ?먮옒 RuntimeException ?좏삎??蹂댁〈?⑸땲?? ?щ윭 cache miss媛€ ?숈떆??諛쒖깮?대룄 紐⑤뱺 ?몄텧?먭? 媛숈? source fetch 寃곌낵瑜??ъ슜?⑸땲?? */ + /** + * ?꾩껜 registry snapshot 議고쉶 寃곌낵瑜?route蹂?memory snapshot??諛섏쁺?⑸땲?? + * ?먯쿇 議고쉶媛€ ?대? ?깃났??紐⑸줉留??ㅼ뼱?ㅻ?濡??붿껌 寃쎈줈?€ Redis 寃쎈줈瑜?嫄대뱶由ъ? ?딄퀬, 湲곗〈 snapshot怨?鍮꾧탳??濡쒓렇?€ 蹂€寃??대깽?몃쭔 泥섎━?⑸땲?? + */ + private void replaceSnapshot(String routeKey, List tools) { + List immutableTools = List.copyOf(tools); + List previous = snapshotsByRoute.put(routeKey, immutableTools); + logSnapshot(routeKey, previous, immutableTools); + publishListChangedIfNeeded(routeKey, previous, immutableTools); + redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools)); + } + private List awaitRefresh(CompletableFuture> refresh) { try { return refresh.join(); @@ -151,7 +253,7 @@ public class ToolRegistryService { } /** - * 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부됩니다. + * ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났?€ ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲?? */ private Optional match(List tools, String name) { return tools.stream() @@ -161,10 +263,59 @@ public class ToolRegistryService { } /** - * 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다. + * 李얠? 紐삵븳 Tool ?대쫫???ы븿??Tool not found ?덉쇅瑜?留뚮벊?덈떎. */ private JsonRpcException notFound(String name) { return new JsonRpcException( JsonRpcErrorCode.TOOL_NOT_FOUND, "Tool not found or disabled: " + name); } + + /** + * 湲곗〈 snapshot??議댁옱?섍퀬 ??snapshot怨??ㅻ? ?뚮쭔 Tool 紐⑸줉 蹂€寃??대깽?몃? 諛쒗뻾?⑸땲?? + * 理쒖큹 濡쒕뵫?€ Agent Builder媛€ ?꾩쭅 紐⑸줉??諛쏄린 ?꾩씪 ???덉쑝誘€濡??뚮┝ ?€?곸뿉???쒖쇅?섍퀬, ?ㅼ젣 援먯껜媛€ 諛쒖깮??refresh?먮쭔 ?곹뼢??以띾땲?? + */ + private void publishListChangedIfNeeded( + String routeKey, List previous, List current) { + if (previous != null && !previous.equals(current)) { + eventPublisher.publishEvent(ToolListChangedEvent.forRoute(routeKey)); + } + } + + /** + * 濡쒖뺄 寃€利앹쓣 ?꾪빐 route蹂?in-memory snapshot??理쒖큹 ?깅줉?섍굅???ㅼ젣 蹂€寃쎈맆 ?뚮쭔 INFO 濡쒓렇濡??④퉩?덈떎. + * Portal ?먮뒗 Tool Service revision 蹂€寃쎌씠 memory??諛섏쁺?섏뿀?붿? ?뺤씤?????덈룄濡?Tool metadata ?꾩껜瑜?湲곕줉?⑸땲?? + */ + private void logSnapshot(String routeKey, List previous, List current) { + boolean changed = previous == null || !previous.equals(current); + if (!changed) { + return; + } + log.info( + "Tool registry in-memory snapshot registered. body={}", + snapshotJson(routeKey, current)); + } + + /** + * 寃€利?濡쒓렇???ъ슜??route蹂?snapshot ?댁슜??JSON 臾몄옄?대줈 蹂€?섑빀?덈떎. + * 吏곷젹???ㅽ뙣媛€ refresh ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?€泥댄빀?덈떎. + */ + private String snapshotJson(String routeKey, List current) { + Map body = new LinkedHashMap<>(); + body.put("routeKey", routeKey); + body.put("toolCount", current.size()); + body.put("snapshotChanged", true); + body.put("tools", current); + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(body); + } catch (JsonProcessingException exception) { + return body.toString(); + } + } + + /** + * route key??null怨?怨듬갚??湲곗〈 ?⑥씪 snapshot key??鍮?臾몄옄?대줈 ?뺢퇋?뷀빀?덈떎. + */ + private String normalizeRouteKey(String routeKey) { + return routeKey == null ? "" : routeKey.trim(); + } } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java index 33912ac..7be5c2a 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java @@ -1,15 +1,16 @@ package io.shinhanlife.dap.biz.mcp.toolclient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.TextNode; import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; - import java.net.SocketTimeoutException; import java.net.http.HttpClient; import java.time.Duration; - import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -18,9 +19,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.node.StringNode; /** * Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다. Registry 기반 {@code tools/call} 실행이 이 구현을 사용하며, 요청 context의 correlation·사원 식별자 @@ -92,10 +90,11 @@ public class HttpToolClient implements ToolClient { set(headers, "employee-no", context.employeeNo()); set(headers, "virtual-employee-no", context.virtualEmployeeNo()); set(headers, "mcp-session-id", context.mcpSessionId()); + set(headers, "X-Tool-Server-API-Key", properties.toolClient().apiKey()); if (properties.toolClient().forwardAuthorization()) { set(headers, "Authorization", context.authorization()); } - }) + }) .contentType(MediaType.APPLICATION_JSON) .body(request.arguments()); } @@ -142,7 +141,7 @@ public class HttpToolClient implements ToolClient { case 403 -> ToolClientException.Kind.FORBIDDEN; default -> ToolClientException.Kind.EXECUTION; }; - return new ToolClientException(kind, "Tool returned HTTP " + status + ": " + toolName, null); + return new ToolClientException(kind, "Tool returned HTTP " + status + ": " + toolName, null, status); } /** @@ -175,12 +174,12 @@ public class HttpToolClient implements ToolClient { return null; } if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { - return StringNode.valueOf(body); + return TextNode.valueOf(body); } try { return objectMapper.readTree(body); } catch (Exception ignored) { - return StringNode.valueOf(body); + return TextNode.valueOf(body); } } } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java index 7e45ac2..9b74628 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java @@ -1,7 +1,7 @@ package io.shinhanlife.dap.biz.mcp.toolclient; +import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; -import tools.jackson.databind.JsonNode; /** * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해 @@ -44,13 +44,24 @@ public interface ToolClient { } private final Kind kind; + private final Integer httpStatusCode; /** * 실패 종류, 안전한 메시지와 원인 예외를 보존합니다. */ public ToolClientException(Kind kind, String message, Throwable cause) { + this(kind, message, cause, null); + } + + /** + * Tool Service가 반환한 HTTP 상태를 함께 보존하는 실행 예외를 만듭니다. + * 상태값은 stale snapshot 감지처럼 HTTP 의미가 필요한 후속 보정 로직에서만 사용하며, + * 일반 timeout·네트워크 장애에는 {@code null}로 둡니다. + */ + public ToolClientException(Kind kind, String message, Throwable cause, Integer httpStatusCode) { super(message, cause); this.kind = kind; + this.httpStatusCode = httpStatusCode; } /** @@ -59,5 +70,13 @@ public interface ToolClient { public Kind kind() { return kind; } + + /** + * upstream Tool Service가 실제로 반환한 HTTP 상태를 반환합니다. + * 상태 기반 복구 판단이 필요한 경우에만 값이 있으며, client 내부 장애나 timeout에는 비어 있습니다. + */ + public java.util.OptionalInt httpStatusCode() { + return httpStatusCode == null ? java.util.OptionalInt.empty() : java.util.OptionalInt.of(httpStatusCode); + } } } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java index d6d4c48..3ccd562 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java @@ -1,5 +1,6 @@ package io.shinhanlife.dap.biz.mcp.transport.http; +import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; @@ -8,15 +9,12 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry; - import java.util.UUID; - import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import tools.jackson.databind.JsonNode; /** * 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. JSON-RPC 요청을 parser로 검증하고 handler로 dispatch하며, notification HTTP 202, initialize 세션 correlation @@ -45,7 +43,7 @@ public class McpController { * 반환합니다. */ @PostMapping( - value = "${mcp.endpoint-path:/mcp}", + value = {"${mcp.endpoint-path:/mcp}", "${mcp.endpoint-path:/mcp}/{routeKey}"}, consumes = {MediaType.APPLICATION_JSON_VALUE, "application/json-rpc"}, produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_EVENT_STREAM_VALUE}) public ResponseEntity handleMcpRequest(@RequestBody JsonNode envelope) { 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 763bfb3..b5d7487 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 @@ -1,5 +1,7 @@ package io.shinhanlife.dap.biz.mcp.transport.http; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; @@ -12,17 +14,13 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - import java.io.IOException; import java.util.Map; - import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; /** * 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 필터입니다. Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다. @@ -64,7 +62,8 @@ public class McpExchangeFilter extends OncePerRequestFilter { if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { path = path.substring(contextPath.length()); } - return !properties.endpointPath().equals(path); + String basePath = properties.endpointPath(); + return !(basePath.equals(path) || path.startsWith(basePath + "/")); } /** @@ -171,7 +170,7 @@ public class McpExchangeFilter extends OncePerRequestFilter { private String extractMethod(CachedBodyHttpServletRequest request) { try { JsonNode envelope = objectMapper.readTree(request.getInputStream()); - return envelope == null ? null : envelope.path("method").asString(null); + return envelope == null ? null : envelope.path("method").asText(null); } catch (Exception ignored) { return null; } diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java index 6680ef4..5b57667 100644 --- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java +++ b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java @@ -5,43 +5,45 @@ import io.shinhanlife.dap.biz.mcp.context.McpRequestContext; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import jakarta.servlet.http.HttpServletRequest; - import java.time.Instant; import java.util.UUID; import java.util.regex.Pattern; - import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * 설정된 MCP endpoint의 HTTP 헤더를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. filter의 가장 앞 단계에서 호출되며 {@code guid}·{@code x-request-id}를 생성 또는 검증하고, - * {@code mcp-session-id}·사원 식별자·deadline을 함께 정리합니다. 사원 식별자는 호출자가 암호화해 보낸 불투명 값이므로 형식·의미를 해석하지 않고 주입 위험 문자만 차단합니다. 주요 의존성은 timeout 설정 {@link McpProperties}이며, - * Authorization 원문은 context 전달 외에는 로그에 남기지 않습니다. + * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. + * filter의 가장 앞 단계에서 호출되며 {@code guid}, {@code x-request-id}, {@code mcp-session-id}, 사원 식별자, deadline을 정리합니다. + * 사원 식별자는 해석하지 않고 주입 위험 문자만 차단하며, 주요 의존성은 timeout 설정을 제공하는 {@link McpProperties}입니다. */ @Component public class McpRequestContextFactory { private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + private static final Pattern SAFE_ROUTE_KEY = Pattern.compile("[A-Za-z0-9._-]{1,64}"); /** - * 암호문은 Base64라 {@code +/=}를 포함한다. 공백·제어문자만 막아 header 주입을 차단하고 내용은 해석하지 않는다. + * 암호문 Base64가 사용할 수 있는 {@code +/=}를 포함합니다. + * 공백과 제어 문자만 차단해 header 주입을 막고, 값의 의미는 MCP가 해석하지 않습니다. */ private static final Pattern SAFE_OPAQUE_TOKEN = Pattern.compile("[\\x21-\\x7E]{1,2048}"); private final McpProperties properties; /** - * 요청 전체 timeout 설정을 주입받습니다. + * 요청 context 생성에 필요한 MCP 설정을 주입받습니다. + * 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path와 timeout을 사용합니다. */ public McpRequestContextFactory(McpProperties properties) { this.properties = properties; } /** - * HTTP 헤더를 읽어 correlation·세션·사원 식별자를 하나의 immutable context로 만듭니다. 다섯 헤더 모두 선택값이며, 로그 상관이 끊기지 않도록 {@code guid}와 {@code x-request-id}만 없을 때 새로 만듭니다. 전체 요청 - * deadline도 이 시점에 계산합니다. + * HTTP 요청에서 route key와 correlation 헤더를 추출해 불변 context로 만듭니다. + * 누락 가능한 헤더는 기본값 또는 {@code null}로 정리하고, 잘못된 route나 header 값은 JSON-RPC invalid request 예외로 거부합니다. */ public McpRequestContext extract(HttpServletRequest request) { + String routeKey = routeKey(request); String authorization = trimToNull(request.getHeader("Authorization")); String requestId = validatedRequestIdOrGenerated(request.getHeader("x-request-id")); String guid = validatedGuidOrGenerated(request.getHeader("guid")); @@ -51,6 +53,7 @@ public class McpRequestContextFactory { opaqueOptional(request.getHeader("virtual-employee-no"), "virtual-employee-no"); return new McpRequestContext( + routeKey, requestId, guid, sessionId, @@ -61,7 +64,67 @@ public class McpRequestContextFactory { } /** - * {@code x-request-id}가 있으면 안전성을 검증하고, 없으면 {@code req-UUID} 형식으로 새 값을 만듭니다. 이 값은 개별 HTTP 요청을 구분하며 end-to-end 상관 값인 {@code guid}와 역할이 다릅니다. + * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다. + * Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다. + */ + private String routeKey(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(); + if (path.equals(basePath)) { + return defaultRouteKey(); + } + String prefix = basePath.endsWith("/") ? basePath : basePath + "/"; + if (!path.startsWith(prefix)) { + return defaultRouteKey(); + } + if (configuredEndpointRouteKey() != null) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not allowed for fixed endpoint path"); + } + String route = path.substring(prefix.length()); + if (route.contains("/") || !SAFE_ROUTE_KEY.matcher(route).matches()) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is invalid"); + } + return route; + } + + /** + * route가 생략된 요청의 처리 방식을 결정합니다. + * Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다. + */ + private String defaultRouteKey() { + String configuredRoute = configuredEndpointRouteKey(); + if (configuredRoute != null) { + return configuredRoute; + } + if (properties.portal() != null && properties.portal().enabled()) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is required"); + } + return ""; + } + + /** + * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다. + * {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다. + */ + private String configuredEndpointRouteKey() { + String basePath = properties.endpointPath(); + String prefix = "/mcp/"; + if (basePath != null && basePath.startsWith(prefix)) { + String route = basePath.substring(prefix.length()); + if (!route.contains("/") && SAFE_ROUTE_KEY.matcher(route).matches()) { + return route; + } + } + return null; + } + + /** + * {@code x-request-id}가 있으면 안전성을 검증하고 없으면 {@code req-UUID} 형식으로 새 값을 만듭니다. + * 값은 개별 HTTP 요청을 구분하며, 잘못된 문자가 있으면 downstream 전파 전에 거부합니다. */ private String validatedRequestIdOrGenerated(String value) { String normalized = trimToNull(value); @@ -73,7 +136,8 @@ public class McpRequestContextFactory { } /** - * {@code guid}가 없으면 표준 UUID를 만들고, 있으면 축약형이나 임의 문자열이 아닌 정규 UUID인지 확인합니다. Agent Builder가 보낸 값은 변경하지 않고 그대로 응답과 Tool Service 호출에 사용합니다. + * {@code guid}가 없으면 표준 UUID를 만들고, 있으면 canonical UUID인지 확인합니다. + * Agent Builder가 보낸 값은 변경하지 않고 응답과 Tool Service 호출에 그대로 사용합니다. */ private String validatedGuidOrGenerated(String value) { if (value == null || value.isEmpty()) { @@ -90,7 +154,8 @@ public class McpRequestContextFactory { } /** - * 암호화된 사원 식별자처럼 MCP가 해석하지 않는 값을 검증합니다. 값의 의미는 보지 않고, 개행·공백이 섞여 downstream 요청 헤더가 조작되는 것만 막습니다. 빈 값은 선택 헤더가 없는 것으로 취급하고 실제 암호문은 한 글자도 변경하지 않습니다. + * MCP가 해석하지 않는 선택 header 값을 단일 line printable token으로 제한합니다. + * 공백이나 제어 문자가 있으면 header injection 위험으로 보고 invalid request 예외를 발생시킵니다. */ private String opaqueOptional(String value, String header) { if (value == null || value.isEmpty()) { @@ -105,7 +170,8 @@ public class McpRequestContextFactory { } /** - * 선택 헤더는 값이 있을 때만 형식 검증을 수행하고, 없으면 null을 반환합니다. + * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. + * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. */ private String validatedOptional(String value, String header) { String normalized = trimToNull(value); @@ -116,7 +182,8 @@ public class McpRequestContextFactory { } /** - * correlation 값이 허용된 문자와 1~128자 길이 규칙을 지키는지 검사합니다. + * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다. + * 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다. */ private void validate(String value, String header) { if (!SAFE_CORRELATION_ID.matcher(value).matches()) { @@ -127,7 +194,8 @@ public class McpRequestContextFactory { } /** - * 공백 문자열을 null로 정규화하고 실제 값은 앞뒤 공백을 제거합니다. + * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다. + * Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다. */ private String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 9ee27cf..9dc1cc3 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,16 +1,16 @@ mcp: + registry: + refresh-interval-seconds: 10 discovery: - # 로컬에서도 먼저 Tool Service manifest를 조회하고, 최초 조회 실패 시 아래 bundle의 fallback 파일을 사용한다. + enabled: false + portal: enabled: true - bundles: - - id: ${MCP_TOOL_BUNDLE_ID:core} - manifest-url: ${MCP_TOOL_MANIFEST_URL:http://localhost:18080/tool-manifest} - base-endpoint: ${MCP_TOOL_BASE_ENDPOINT:http://localhost:18080/mcp} - name-prefix: ${MCP_TOOL_NAME_PREFIX:core.} - fallback-manifest-file: ${MCP_FALLBACK_MANIFEST_FILE:file:./config/local-core-tools-manifest-sample-v1.json} - enabled: true + registry-url: http://localhost:7070/api/portal/registry + refresh-interval-seconds: 15 + bundles: [] redis: enabled: false + management: health: redis: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7389e3e..88c5ef0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,16 +51,17 @@ mcp: version: 1.0.0 protocol: supported-versions: - - "2025-06-18" - preferred-version: "2025-06-18" + - "2025-11-25" + preferred-version: "2025-11-25" registry: # local profile uses this file instead of opening a separate Registry HTTP port. local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:file:./config/local-core-tools-manifest-sample-v1.json} - refresh-interval-seconds: 30 + refresh-interval-seconds: ${MCP_REGISTRY_REFRESH_INTERVAL_SECONDS:10} refresh-jitter-seconds: 5 tool-client: connect-timeout-millis: 1000 read-timeout-millis: 5000 + api-key: ${TOOL_SERVER_API_KEY:tool-server-key} # One Agent Builder -> MCP request budget. Agent Builder drops the connection at 300s, # so MCP must give up FIRST or its answer arrives after nobody is listening. # 270s leaves a 30s margin to serialize and write the timeout response. @@ -69,6 +70,9 @@ mcp: redis: enabled: true key-prefix: axhub:mcp:tools + # Portal writes the endpoint registry JSON here. MCP reads it only when the + # Portal API is unavailable and no in-memory endpoint snapshot exists. + portal-registry-key: ${MCP_PORTAL_REGISTRY_REDIS_KEY:axhub:mcp:portal-registry} discovery: # local=false uses the local JSON fixture; non-local deployments must enable Tool Service manifest pull. enabled: ${MCP_DISCOVERY_ENABLED:false} @@ -79,6 +83,11 @@ mcp: max-manifest-bytes: 1048576 # Upper bound applied to the timeout a manifest declares, so one Tool cannot consume the whole request budget. max-tool-timeout-millis: 30000 + portal: + enabled: ${MCP_PORTAL_ENABLED:false} + route-key: ${MCP_PORTAL_ROUTE_KEY:} + registry-url: ${MCP_PORTAL_REGISTRY_URL:} + refresh-interval-seconds: ${MCP_PORTAL_REFRESH_INTERVAL_SECONDS:300} # 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/dap/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java index 50dc0ef..c587abf 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java @@ -1,13 +1,12 @@ package io.shinhanlife.dap.biz.mcp; +import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.biz.mcp.config.McpProperties; 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 tools.jackson.databind.ObjectMapper; +import java.util.Map; public final class TestFixtures { @@ -28,21 +27,23 @@ public final class TestFixtures { new McpProperties.Server("shl-axhub-mcp-server", "SHL AX HUB MCP Server", "1.0.0"), new McpProperties.Registry( "file:./config/local-core-tools-manifest-sample-v1.json", 30, 5), - new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization), - new McpProperties.Redis(redisEnabled, "test:mcp:tools"), + new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization, "tool-server-key"), + new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"), new McpProperties.Trace(true, 1_048_576), - new McpProperties.Protocol(List.of("2025-06-18"), "2025-06-18"), + new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"), new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000), + new McpProperties.Portal(false, "", "", 300), bundles); } public static McpProperties.Bundle bundle( String id, String manifestUrl, String baseEndpoint, String namePrefix) { - return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null); + return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null, Map.of()); } public static McpRequestContext context() { return new McpRequestContext( + "external", "req-1", "guid-1", "session-1", 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 a196a56..f5415d8 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,6 +5,7 @@ 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; @@ -67,6 +68,7 @@ class McpBundleConfigurationTest { null, null, new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000), + new McpProperties.Portal(false, "", "", 300), List.of()); assertThat(properties.isDiscoveryTargetDeclared()).isFalse(); @@ -81,7 +83,8 @@ class McpBundleConfigurationTest { "http://tool/mcp", "disabled.", false, - null); + null, + Map.of()); McpProperties properties = properties(false, false, List.of(disabled)); assertThat(properties.isDiscoveryTargetDeclared()).isFalse(); @@ -90,7 +93,7 @@ class McpBundleConfigurationTest { @Test void treatsAMissingBundleListAsEmpty() { McpProperties properties = - new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null); + new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null, null); assertThat(properties.bundles()).isEmpty(); assertThat(properties.enabledBundles()).isEmpty(); diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java index 3b21b6a..7d7d121 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java @@ -8,7 +8,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; import io.shinhanlife.dap.biz.mcp.execute.ToolArgumentValidator; import io.shinhanlife.dap.biz.mcp.execute.ToolCall; import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService; @@ -21,17 +23,13 @@ import io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler; import io.shinhanlife.dap.biz.mcp.method.ToolsListHandler; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; - import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; - import org.junit.jupiter.api.Test; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.node.ObjectNode; /** * `docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/`의 공개 계약 예제를 실제 handler 출력과 대조하는 golden 계약 테스트입니다. 예제 JSON을 테스트가 직접 읽으므로 문서와 코드가 조용히 어긋나면 실패합니다. @@ -75,9 +73,9 @@ class AgentBuilderContractExampleTest { for (JsonNode tool : golden.path("result").path("tools")) { registryTools.add( new ToolMetadata( - tool.path("name").asString(), + tool.path("name").asText(), "1.0.0", - tool.path("description").asString(), + tool.path("description").asText(), "https://tool.example/mcp", tool.get("inputSchema"), 3_000, @@ -85,7 +83,7 @@ class AgentBuilderContractExampleTest { tool)); } ToolRegistryService registryService = mock(ToolRegistryService.class); - when(registryService.listTools()).thenReturn(registryTools); + when(registryService.listTools(context().routeKey())).thenReturn(registryTools); JsonRpcRequest request = new JsonRpcRequest("tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id")); @@ -111,13 +109,13 @@ class AgentBuilderContractExampleTest { "{\"endpoint\":\"https://internal.example/mcp\",\"timeoutMillis\":3000}")); ToolRegistryService registryService = mock(ToolRegistryService.class); - when(registryService.listTools()) + when(registryService.listTools(context().routeKey())) .thenReturn( List.of( new ToolMetadata( - first.path("name").asString(), + first.path("name").asText(), "1.0.0", - first.path("description").asString(), + first.path("description").asText(), "https://tool.example/mcp", first.get("inputSchema"), 3_000, @@ -145,11 +143,11 @@ class AgentBuilderContractExampleTest { when(service.execute(any(), any())) .thenReturn( new ToolExecutionService.Result( - OBJECT_MAPPER.getNodeFactory().stringNode(goldenContent.path("text").asString()), + OBJECT_MAPPER.getNodeFactory().textNode(goldenContent.path("text").asText()), goldenContent.path("_meta").path("searchTime").asDouble())); JsonRpcRequest request = new JsonRpcRequest( - requestExample.path("method").asString(), + requestExample.path("method").asText(), requestExample.get("params"), requestExample.get("id")); @@ -162,7 +160,7 @@ class AgentBuilderContractExampleTest { @Test void toolsCallExecutionErrorResponseMatchesThePublishedExample() throws Exception { JsonNode golden = example("tools-call-execution-error-response.json"); - String goldenText = golden.path("result").path("content").get(0).path("text").asString(); + String goldenText = golden.path("result").path("content").get(0).path("text").asText(); ToolExecutionService service = mock(ToolExecutionService.class); when(service.execute(any(), any())) 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 039be80..6ac24ef 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 @@ -5,11 +5,12 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle; import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dap.biz.mcp.config.McpProperties; import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery; import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; - import java.lang.reflect.RecordComponent; import java.nio.file.Files; import java.nio.file.Path; @@ -18,7 +19,6 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; - import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; @@ -26,8 +26,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; -import tools.jackson.core.type.TypeReference; -import tools.jackson.databind.JsonNode; /** * 계약 문서의 bundle 예제 JSON을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다. 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다. 조회 대상은 예제 매니페스트를 그대로 돌려주는 @@ -94,7 +92,7 @@ class ToolBundleContractExampleTest { } /** - * 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-06-18에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재 + * 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-11-25에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재 * {@code tools/call}은 {@code content[0].text}만 반환합니다. 예제가 이 규칙을 어기면 Tool 개발자가 예제를 그대로 베껴 표준 위반 매니페스트를 만들게 되므로 계약(§5)을 테스트로 고정합니다. */ @Test @@ -108,7 +106,7 @@ class ToolBundleContractExampleTest { assertThat(tool.has("outputSchema")) .withFailMessage( "운영 매니페스트 예제는 outputSchema를 선언하지 않는다 (v0.2 §5): %s", - tool.path("name").asString()) + tool.path("name").asText()) .isFalse()); } @@ -130,7 +128,7 @@ class ToolBundleContractExampleTest { // 문서 예제와 구현 응답의 field가 어긋나면 운영자가 없는 field를 보고 대시보드를 만들게 된다. assertThat(documented).containsExactlyInAnyOrderElementsOf(implemented); assertThat(example.path("bundles")) - .anySatisfy(node -> assertThat(node.path("status").asString()).isEqualTo("disabled")); + .anySatisfy(node -> assertThat(node.path("status").asText()).isEqualTo("disabled")); } /** diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java index 9ab9c7c..a802bfa 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java @@ -11,12 +11,10 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; - import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.snakeyaml.engine.v2.api.Load; -import org.snakeyaml.engine.v2.api.LoadSettings; +import org.yaml.snakeyaml.Yaml; /** * Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라 @@ -316,8 +314,7 @@ class HelmDeploymentContractTest { */ @SuppressWarnings("unchecked") private Map loadYaml(Path path) throws IOException { - Load load = new Load(LoadSettings.builder().build()); - Object loaded = load.loadFromString(Files.readString(path)); + Object loaded = new Yaml().load(Files.readString(path)); return loaded == null ? new LinkedHashMap<>() : (Map) loaded; } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java index c092d44..576b80a 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java @@ -4,7 +4,7 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java index ce66262..9f3d0c5 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java @@ -4,14 +4,18 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient; +import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; import org.junit.jupiter.api.Test; @@ -29,7 +33,7 @@ class ToolExecutionServiceTest { ToolMetadata metadata = tool("http://tool/one"); ToolRequest request = new ToolRequest("customer.search", "1.0.0", "http://tool/one", call.arguments(), 3_000); - when(registry.findEnabledTool(call.toolName())).thenReturn(metadata); + when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata); when(routing.route(call, metadata)).thenReturn(request); when(client.execute(request, context())) .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"order\":1}"))); @@ -54,7 +58,7 @@ class ToolExecutionServiceTest { ToolRequest request = new ToolRequest( "weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000); - when(registry.findEnabledTool(call.toolName())).thenReturn(metadata); + when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata); when(routing.route(call, metadata)).thenReturn(request); when(client.execute(request, context())) .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}"))); @@ -66,4 +70,60 @@ class ToolExecutionServiceTest { verify(validator).validate(call, metadata); verify(client).execute(request, context()); } + + @Test + void refreshesRouteWhenDeletedToolReturnsNotFound() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/removed"); + ToolRequest request = + new ToolRequest("customer.search", "1.0.0", "http://tool/removed", call.arguments(), 3_000); + when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, context())) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 404: customer.search", + null, + 404)); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + assertThatThrownBy(() -> service.execute(call, context())) + .isInstanceOf(JsonRpcException.class) + .hasMessageContaining("Tool returned HTTP 404"); + + verify(registry).refresh(context().routeKey()); + } + + @Test + void doesNotRefreshRouteForNonStaleToolFailure() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/error"); + ToolRequest request = + new ToolRequest("customer.search", "1.0.0", "http://tool/error", call.arguments(), 3_000); + when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, context())) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 500: customer.search", + null, + 500)); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + assertThatThrownBy(() -> service.execute(call, context())) + .isInstanceOf(JsonRpcException.class) + .hasMessageContaining("Tool returned HTTP 500"); + + verify(registry, never()).refresh(context().routeKey()); + } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java index 21e404a..d9d3765 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java @@ -28,4 +28,17 @@ class ToolRoutingServiceTest { assertThat(request.endpoint()).isEqualTo("https://axhub-tool-other.onrender.com/mcp/weather"); assertThat(request.arguments()).isNotSameAs(call.arguments()); } + + @Test + void usesExactPortalManagedEndpointWithoutAppendingToolName() throws Exception { + ToolMetadata metadata = new ToolMetadata( + "business.customer_search", "1.0.0", "search", + "http://localhost:9090/internal/tools/customer-search", + null, 2_500, true, null, true); + ToolCall call = new ToolCall("business.customer_search", OBJECT_MAPPER.readTree("{\"keyword\":\"kim\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.endpoint()).isEqualTo("http://localhost:9090/internal/tools/customer-search"); + } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java index 678b748..8aa8932 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java @@ -3,9 +3,9 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; class JsonRpcRequestParserTest { @@ -27,7 +27,7 @@ class JsonRpcRequestParserTest { """)); assertThat(request.method()).isEqualTo("tools/list"); - assertThat(request.id().asString()).isEqualTo("req-1"); + assertThat(request.id().asText()).isEqualTo("req-1"); } @Test @@ -43,7 +43,7 @@ class JsonRpcRequestParserTest { JsonRpcException.class, exception -> { assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_REQUEST); - assertThat(exception.requestId().asString()).isEqualTo("req-2"); + assertThat(exception.requestId().asText()).isEqualTo("req-2"); }); } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java index 968348c..f07686e 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java @@ -3,15 +3,15 @@ package io.shinhanlife.dap.biz.mcp.method; import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import org.junit.jupiter.api.Test; -import tools.jackson.databind.node.JsonNodeFactory; class InitializeHandlerTest { @Test - void returnsConfiguredInitializeCapabilityAndServerInformation() { + void returnsConfiguredInitializeCapabilityAndServerInformation() throws Exception { InitializeHandler handler = new InitializeHandler(properties(false, false)); JsonRpcRequest request = new JsonRpcRequest( @@ -19,26 +19,45 @@ class InitializeHandlerTest { JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(1)); - var response = handler.handle(request, null); + var response = handler.handle(request, io.shinhanlife.dap.biz.mcp.TestFixtures.context()); assertThat(response.jsonrpc()).isEqualTo("2.0"); assertThat(response.id().asInt()).isEqualTo(1); assertThat(response.result()).isInstanceOf(McpSchema.InitializeResult.class); - tools.jackson.databind.JsonNode serialized = + com.fasterxml.jackson.databind.JsonNode serialized = io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result()); assertThat(serialized) .isEqualTo( io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree( """ { - "protocolVersion":"2025-06-18", - "capabilities":{"tools":{"listChanged":false}}, + "protocolVersion":"2025-11-25", + "capabilities":{"tools":{"listChanged":true}}, "serverInfo":{ - "name":"shl-axhub-mcp-server", - "title":"SHL AX HUB MCP Server", + "name":"shl-axhub-mcp-server-external", + "title":"SHL AX HUB MCP Server (EXTERNAL)", "version":"1.0.0" } } """)); } + + @Test + void returnsRouteSpecificServerInformationForOth() { + InitializeHandler handler = new InitializeHandler(properties(false, false)); + JsonRpcRequest request = new JsonRpcRequest( + "initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(2)); + var base = io.shinhanlife.dap.biz.mcp.TestFixtures.context(); + var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext( + "oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(), + base.virtualEmployeeNo(), base.authorization(), base.deadline()); + + var response = handler.handle(request, othContext); + var serialized = io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result()); + + assertThat(serialized.path("serverInfo").path("name").asText()) + .isEqualTo("shl-axhub-mcp-server-oth"); + assertThat(serialized.path("serverInfo").path("title").asText()) + .isEqualTo("SHL AX HUB MCP Server (OTH)"); + } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java index 012672a..ed91ee8 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java @@ -3,9 +3,9 @@ package io.shinhanlife.dap.biz.mcp.method; import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import org.junit.jupiter.api.Test; -import tools.jackson.databind.node.JsonNodeFactory; class InitializedNotificationHandlerTest { diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java index a001d90..984f899 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.execute.ToolCall; import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService; @@ -17,7 +18,6 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import tools.jackson.databind.JsonNode; class ToolsCallHandlerTest { @@ -77,7 +77,7 @@ class ToolsCallHandlerTest { .path("content") .get(0) .path("text") - .asString()) + .asText()) .isEqualTo(toolResponse); } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java index e639a6b..ec163f2 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java @@ -10,9 +10,7 @@ import static org.mockito.Mockito.when; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; - import java.util.List; - import org.junit.jupiter.api.Test; class ToolsListHandlerTest { @@ -20,7 +18,7 @@ class ToolsListHandlerTest { @Test void exposesOnlyMcpToolFieldsAndHidesInternalRegistryMetadata() throws Exception { ToolRegistryService registryService = mock(ToolRegistryService.class); - when(registryService.listTools()) + when(registryService.listTools(context().routeKey())) .thenReturn(List.of(tool("http://internal-tool.example/search"))); ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); JsonRpcRequest request = @@ -29,7 +27,7 @@ class ToolsListHandlerTest { var response = handler.handle(request, context()); assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class); - tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + com.fasterxml.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); assertThat(serialized) .isEqualTo( OBJECT_MAPPER.readTree( @@ -68,7 +66,7 @@ class ToolsListHandlerTest { 3_000, true, publicDefinition); - when(registryService.listTools()).thenReturn(List.of(metadata)); + when(registryService.listTools(context().routeKey())).thenReturn(List.of(metadata)); ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); JsonRpcRequest request = new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2")); @@ -81,7 +79,7 @@ class ToolsListHandlerTest { } @Test - void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() { + void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() throws Exception { ToolRegistryService registryService = mock(ToolRegistryService.class); var metadata = new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata( @@ -93,7 +91,7 @@ class ToolsListHandlerTest { 3_000, true, null); - when(registryService.listTools()).thenReturn(List.of(metadata)); + when(registryService.listTools(context().routeKey())).thenReturn(List.of(metadata)); ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER); JsonRpcRequest request = new JsonRpcRequest( @@ -103,7 +101,7 @@ class ToolsListHandlerTest { var response = handler.handle(request, context()); - tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); + com.fasterxml.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result()); assertThat(serialized.path("tools").get(0).path("inputSchema")) .isEqualTo( OBJECT_MAPPER.readTree( diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java index cae8bc2..1941bcc 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java @@ -8,10 +8,8 @@ import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; - import org.junit.jupiter.api.Test; -import org.snakeyaml.engine.v2.api.Load; -import org.snakeyaml.engine.v2.api.LoadSettings; +import org.yaml.snakeyaml.Yaml; /** * {@code toolCatalog} health indicator가 어느 probe에 연결되는지 고정하는 계약 테스트입니다. @@ -69,8 +67,7 @@ class HealthGroupContractTest { */ @SuppressWarnings("unchecked") private Map loadYaml() throws IOException { - Load load = new Load(LoadSettings.builder().build()); - Object loaded = load.loadFromString(Files.readString(APPLICATION_YML)); + Object loaded = new Yaml().load(Files.readString(APPLICATION_YML)); return loaded == null ? new LinkedHashMap<>() : (Map) loaded; } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java index d4c57a1..4234abb 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java @@ -6,9 +6,8 @@ import static org.mockito.Mockito.when; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler; import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService; - import org.junit.jupiter.api.Test; -import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.actuate.health.Status; class ToolCatalogHealthIndicatorTest { 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 new file mode 100644 index 0000000..581b531 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java @@ -0,0 +1,201 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +class PortalToolRegistryClientTest { + + private MockWebServer portal; + private MockWebServer toolServer; + + @BeforeEach + void setUp() throws Exception { + portal = new MockWebServer(); + portal.start(); + toolServer = new MockWebServer(); + toolServer.start(); + } + + @AfterEach + void tearDown() throws Exception { + portal.shutdown(); + toolServer.shutdown(); + } + + @Test + void refreshesToolManifestWithoutRefreshingPortalRegistry() { + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(manifest("manifest-1", "external.weather")); + toolServer.enqueue(manifest("manifest-2", "external.exchange")); + + PortalToolRegistryClient client = client(); + + Map> first = client.fetchAllTools(); + Map> second = client.fetchAllTools(); + + assertThat(first.get("external")) + .extracting(ToolMetadata::name) + .containsExactly("external.weather"); + assertThat(second.get("external")) + .extracting(ToolMetadata::name) + .containsExactly("external.exchange"); + assertThat(portal.getRequestCount()).isEqualTo(1); + assertThat(toolServer.getRequestCount()).isEqualTo(2); + } + + @Test + void keepsMemoryEndpointSnapshotWhenPortalRegistryRefreshFails() { + portal.enqueue(portalRegistry("portal-1")); + portal.enqueue(new MockResponse().setResponseCode(503)); + toolServer.enqueue(manifest("manifest-1", "external.weather")); + RedisPortalRegistryCache redis = mock(RedisPortalRegistryCache.class); + PortalToolRegistryClient client = client(Optional.of(redis)); + + client.fetchAllTools(); + boolean changed = client.refreshSourceRegistry(); + + assertThat(changed).isFalse(); + assertThat(portal.getRequestCount()).isEqualTo(2); + verify(redis, never()).loadRegistry(); + } + + @Test + void loadsEndpointRegistryFromRedisWhenPortalFailsOnColdStart() throws Exception { + portal.enqueue(new MockResponse().setResponseCode(503)); + toolServer.enqueue(manifest("manifest-1", "external.weather")); + RedisPortalRegistryCache redis = mock(RedisPortalRegistryCache.class); + when(redis.loadRegistry()).thenReturn(Optional.of(OBJECT_MAPPER.readTree(portalRegistryJson("redis-1")))); + when(redis.key()).thenReturn("test:mcp:portal-registry"); + PortalToolRegistryClient client = client(Optional.of(redis)); + + Map> snapshots = client.fetchAllTools(); + + assertThat(snapshots.get("external")) + .extracting(ToolMetadata::name) + .containsExactly("external.weather"); + verify(redis).loadRegistry(); + assertThat(portal.getRequestCount()).isEqualTo(1); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + } + + + @Test + void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() { + PortalToolRegistryClient client = client(); + + assertThatThrownBy(() -> client.fetchTools("")) + .hasMessageContaining("Portal registry routeKey is required"); + assertThat(portal.getRequestCount()).isZero(); + assertThat(toolServer.getRequestCount()).isZero(); + } + + private PortalToolRegistryClient client() { + return client(Optional.empty()); + } + + private PortalToolRegistryClient client(Optional redisPortalRegistryCache) { + RestClient restClient = RestClient.builder() + .requestFactory(new SimpleClientHttpRequestFactory()) + .build(); + McpProperties base = properties(false, false); + McpProperties mcpProperties = new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + base.registry(), + base.toolClient(), + base.redis(), + base.trace(), + base.protocol(), + base.discovery(), + new McpProperties.Portal(true, "", portal.url("/api/portal/registry").toString(), 15), + List.of()); + ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties); + return new PortalToolRegistryClient(restClient, mcpProperties, discovery, redisPortalRegistryCache); + } + + private MockResponse portalRegistry(String revision) { + return jsonResponse(portalRegistryJson(revision)); + } + + private String portalRegistryJson(String revision) { + return """ + { + "registryRevision": "%s", + "routes": [ + { + "routeKey": "external", + "toolServices": [ + { + "serviceKey": "external-tool-server", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "executeBasePath": "/tools", + "namePrefix": "external.", + "status": "ACTIVE", + "toolEndpoints": { + "external.weather": "/weather", + "external.exchange": "/exchange" + } + } + ] + } + ] + } + """ + .formatted(revision, toolServer.url("").toString().replaceAll("/+$", "")); + } + + private MockResponse manifest(String revision, String toolName) { + return jsonResponse( + """ + { + "bundleId": "external-tool-server", + "revision": "%s", + "tools": [ + { + "name": "%s", + "description": "test tool", + "inputSchema": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "_meta": { + "version": "1.0.0", + "enabled": true + } + } + ] + } + """ + .formatted(revision, toolName)); + } + + private MockResponse jsonResponse(String body) { + return new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(body); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java index 18323f4..9e65ab3 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java @@ -46,6 +46,16 @@ class RedisToolRegistryCacheTest { .doesNotThrowAnyException(); } + + @Test + void keepsDifferentRoutesInDifferentRedisKeys() { + StringRedisTemplate template = mock(StringRedisTemplate.class); + RedisToolRegistryCache cache = + new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false)); + + assertThat(cache.key("external")).isNotEqualTo(cache.key("business")); + assertThat(cache.key("external")).startsWith("test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":route:"); + } @Test void namespacesKeyByMcpIdentityAndCacheSchemaVersion() { StringRedisTemplate template = mock(StringRedisTemplate.class); @@ -56,6 +66,6 @@ class RedisToolRegistryCacheTest { // 캐시 구조가 바뀐 버전이 옛 데이터를 읽어 오염되지 않아야 한다. assertThat(cache.key()) .isEqualTo( - "test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":all"); + "test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":route:_default"); } } 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 59a85e8..997cb54 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,6 +13,7 @@ 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; @@ -111,7 +112,8 @@ class ToolBundleDiscoveryTest { "http://tool-core/mcp", "core.", true, - "file:./config/local-core-tools-manifest-sample-v1.json")); + "file:./config/local-core-tools-manifest-sample-v1.json", + Map.of())); assertThat(client(properties).fetchTools()) .extracting(ToolMetadata::name) @@ -334,6 +336,7 @@ class ToolBundleDiscoveryTest { base.protocol(), new McpProperties.Discovery( true, 1_000, 3_000, 100, maxToolsTotal, maxManifestBytes, 30_000), + base.portal(), bundles); } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java new file mode 100644 index 0000000..c5a3641 --- /dev/null +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java @@ -0,0 +1,35 @@ +package io.shinhanlife.dap.biz.mcp.registry; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +class ToolRegistryRefreshSchedulerTest { + + @Test + void refreshesManifestImmediatelyWhenPortalRegistryChanges() { + ToolRegistryService service = mock(ToolRegistryService.class); + when(service.refreshSourceRegistry()).thenReturn(true); + ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service); + + scheduler.scheduledPortalRefresh(); + + verify(service).refreshSourceRegistry(); + verify(service).refreshKnownRoutes(); + } + + @Test + void keepsManifestScheduleSeparateWhenPortalRegistryIsUnchanged() { + ToolRegistryService service = mock(ToolRegistryService.class); + when(service.refreshSourceRegistry()).thenReturn(false); + ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service); + + scheduler.scheduledPortalRefresh(); + + verify(service).refreshSourceRegistry(); + verify(service, never()).refreshKnownRoutes(); + } +} diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java index 8fdb1df..b5580b3 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java @@ -4,6 +4,7 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -14,14 +15,15 @@ import static org.mockito.Mockito.when; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; - import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; - import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; class ToolRegistryServiceTest { @@ -29,7 +31,7 @@ class ToolRegistryServiceTest { void usesMemorySnapshotWithoutTouchingRedisOrSourceOnTheRequestPath() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool"))); + when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); service.refresh(); clearInvocations(client, redis); @@ -43,7 +45,7 @@ class ToolRegistryServiceTest { void keepsPreviousSnapshotWhenSourceRefreshFails() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(client.fetchTools()) + when(client.fetchTools("")) .thenReturn(List.of(tool("http://memory-tool"))) .thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "source down")); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); @@ -53,32 +55,48 @@ class ToolRegistryServiceTest { .singleElement() .extracting(ToolMetadata::endpoint) .isEqualTo("http://memory-tool"); - verify(redis, never()).loadSnapshot(); + verify(redis, never()).loadSnapshot(""); } @Test void adoptsSharedSnapshotWhenFirstSourceFetchFails() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(client.fetchTools()) + when(client.fetchTools("")) .thenThrow( new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); - when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); + when(redis.loadSnapshot("")).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); assertThat(service.refresh()) .singleElement() .extracting(ToolMetadata::endpoint) .isEqualTo("http://shared-tool"); - verify(redis, never()).saveSnapshot(any()); + verify(redis, never()).saveSnapshot(eq(""), any()); } + + @Test + void usesRouteSpecificSharedSnapshotWhenRouteRefreshFails() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); + when(client.fetchTools("external")) + .thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); + when(redis.loadSnapshot("external")).thenReturn(Optional.of(List.of(tool("http://external-shared-tool")))); + ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); + + assertThat(service.refresh("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://external-shared-tool"); + verify(redis).loadSnapshot("external"); + } @Test void sharesOneSourceFetchAcrossConcurrentRefreshCalls() throws Exception { ToolRegistryClient client = mock(ToolRegistryClient.class); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); - when(client.fetchTools()) + when(client.fetchTools("")) .thenAnswer( invocation -> { entered.countDown(); @@ -88,25 +106,25 @@ class ToolRegistryServiceTest { ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { - var first = executor.submit(service::refresh); + var first = executor.submit(() -> service.refresh()); assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); - var second = executor.submit(service::refresh); + var second = executor.submit(() -> service.refresh()); release.countDown(); assertThat(first.get(5, TimeUnit.SECONDS)).hasSize(1); assertThat(second.get(5, TimeUnit.SECONDS)).hasSize(1); } - verify(client, times(1)).fetchTools(); + verify(client, times(1)).fetchTools(""); } @Test void propagatesSourceFailureWhenNoSnapshotExists() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(client.fetchTools()) + when(client.fetchTools("")) .thenThrow( new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); - when(redis.loadSnapshot()).thenReturn(Optional.empty()); + when(redis.loadSnapshot("")).thenReturn(Optional.empty()); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); assertThatThrownBy(service::refresh) @@ -121,7 +139,7 @@ class ToolRegistryServiceTest { void warmStartsFromSharedCacheOnlyBeforeMemoryIsLoaded() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); + when(redis.loadSnapshot("")).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); service.warmStartFromSharedCache(); @@ -131,7 +149,7 @@ class ToolRegistryServiceTest { .singleElement() .extracting(ToolMetadata::endpoint) .isEqualTo("http://shared-tool"); - verify(redis, times(1)).loadSnapshot(); + verify(redis, times(1)).loadSnapshot(""); verifyNoInteractions(client); } @@ -139,19 +157,19 @@ class ToolRegistryServiceTest { void writesSharedCacheOnlyAfterSuccessfulSourceFetch() { ToolRegistryClient client = mock(ToolRegistryClient.class); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); - when(client.fetchTools()).thenReturn(List.of(tool("http://direct-tool"))); + when(client.fetchTools("")).thenReturn(List.of(tool("http://direct-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); service.refresh(); - verify(redis).saveSnapshot(any()); - verify(redis, never()).loadSnapshot(); + verify(redis).saveSnapshot(eq(""), any()); + verify(redis, never()).loadSnapshot(""); } @Test void treatsASuccessfulEmptyCatalogAsAUsableSnapshot() { ToolRegistryClient client = mock(ToolRegistryClient.class); - when(client.fetchTools()).thenReturn(List.of()); + when(client.fetchTools("")).thenReturn(List.of()); ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); service.refresh(); @@ -163,9 +181,72 @@ class ToolRegistryServiceTest { @Test void resolvesEnabledToolByItsStandardName() { ToolRegistryClient client = mock(ToolRegistryClient.class); - when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool"))); + when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); assertThat(service.findEnabledTool("customer.search").version()).isEqualTo("1.0.0"); } + + @Test + void keepsIndependentSnapshotsPerRoute() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchTools("external")).thenReturn(List.of(tool("http://external-tool"))); + when(client.fetchTools("sms")).thenReturn(List.of(tool("http://sms-tool"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://external-tool"); + assertThat(service.listTools("sms")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://sms-tool"); + } + + @Test + void refreshesAllPortalRoutesFromOneAggregateRegistrySnapshot() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchAllTools()).thenReturn(Map.of( + "external", List.of(tool("http://external-tool")), + "business", List.of(tool("http://business-tool")))); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + + service.refreshKnownRoutes(); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://external-tool"); + assertThat(service.listTools("business")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://business-tool"); + verify(client, never()).fetchTools("external"); + verify(client, never()).fetchTools("business"); + } + + @Test + void publishesToolsListChangedOnlyWhenExistingRouteSnapshotChanges() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://first-tool"))) + .thenReturn(List.of(tool("http://first-tool"))) + .thenReturn(List.of(tool("http://second-tool"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty(), publisher); + + service.refresh("external"); + service.refresh("external"); + verifyNoInteractions(publisher); + + service.refresh("external"); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ToolListChangedEvent.class); + verify(publisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().routeKey()).isEqualTo("external"); + assertThat(eventCaptor.getValue().notification().method()) + .isEqualTo("notifications/tools/list_changed"); + } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java index 65aa271..5721a91 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java @@ -4,13 +4,14 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; 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 io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest; import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse; - import java.net.http.HttpClient; +import java.time.Instant; import java.util.concurrent.TimeUnit; - import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -51,7 +52,7 @@ class HttpToolClientTest { ToolResponse response = client.execute(request, context()); - assertThat(response.data().path("customerName").asString()).isEqualTo("홍길동"); + assertThat(response.data().path("customerName").asText()).isEqualTo("홍길동"); RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS); assertThat(recorded).isNotNull(); assertThat(recorded.getMethod()).isEqualTo("POST"); @@ -61,9 +62,35 @@ class HttpToolClientTest { assertThat(recorded.getHeader("mcp-session-id")).isEqualTo("session-1"); assertThat(recorded.getHeader("employee-no")).isEqualTo("ENC(employee-1)"); assertThat(recorded.getHeader("virtual-employee-no")).isEqualTo("ENC(virtual-1)"); + assertThat(recorded.getHeader("X-Tool-Server-API-Key")).isEqualTo("tool-server-key"); assertThat(recorded.getHeader("x-trace-id")).isNull(); assertThat(recorded.getHeader("Authorization")).isNull(); - assertThat(recorded.getBody().readUtf8()).contains("1234567890"); + assertThat(recorded.getBody().readUtf8()) + .contains("customerNo", "1234567890") + .doesNotContain("arguments"); + } + + @Test + void postsDirectArgumentsForEveryToolServer() throws Exception { + server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("{\"quote\":\"시작이 반이다.\"}")); + HttpToolClient client = + new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient()); + ToolRequest request = new ToolRequest( + "smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(), + OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000); + var base = context(); + var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext( + "oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(), + base.virtualEmployeeNo(), base.authorization(), Instant.now().plusSeconds(10)); + + client.execute(request, othContext); + + RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS); + assertThat(recorded).isNotNull(); + assertThat(recorded.getBody().readUtf8()) + .contains("category", "속담") + .doesNotContain("arguments"); } @Test @@ -81,7 +108,27 @@ class HttpToolClientTest { ToolResponse response = client.execute(request, context()); - assertThat(response.data().isString()).isTrue(); - assertThat(response.data().asString()).isEqualTo("123"); + assertThat(response.data().isTextual()).isTrue(); + assertThat(response.data().asText()).isEqualTo("123"); + } + + @Test + void preservesHttpStatusOnToolError() throws Exception { + server.enqueue(new MockResponse().setResponseCode(410).setBody("gone")); + HttpToolClient client = + new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient()); + ToolRequest request = + new ToolRequest( + "customer.search", + "1.0.0", + server.url("/api/v1/search").toString(), + OBJECT_MAPPER.readTree("{\"customerNo\":\"1234567890\"}"), + 3_000); + + assertThatThrownBy(() -> client.execute(request, context())) + .isInstanceOfSatisfying(ToolClientException.class, exception -> { + assertThat(exception.kind()).isEqualTo(ToolClientException.Kind.EXECUTION); + assertThat(exception.httpStatusCode()).hasValue(410); + }); } } diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java index 7f6f834..02e6c0a 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java @@ -6,22 +6,20 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse; import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry; import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry.Handler; - import java.util.Map; import java.util.UUID; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; -import tools.jackson.databind.node.JsonNodeFactory; class McpControllerTest { @@ -95,7 +93,7 @@ class McpControllerTest { PostMapping mapping = McpController.class - .getMethod("handleMcpRequest", tools.jackson.databind.JsonNode.class) + .getMethod("handleMcpRequest", com.fasterxml.jackson.databind.JsonNode.class) .getAnnotation(PostMapping.class); assertThat(mapping.produces()).contains(MediaType.TEXT_EVENT_STREAM_VALUE); assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java index b923376..866307c 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java @@ -77,7 +77,7 @@ class McpEndpointMethodContractTest { @Test void deleteMcpReturns405SoSessionTerminationIsNotMistakenForSuccess() throws Exception { mockMvc - .perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-06-18")) + .perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-11-25")) .andExpect(status().isMethodNotAllowed()) .andExpect(header().string("Allow", "POST")) .andExpect(content().string("")); @@ -102,15 +102,30 @@ class McpEndpointMethodContractTest { .content( """ {"jsonrpc":"2.0","method":"initialize", - "params":{"protocolVersion":"2025-06-18","capabilities":{}, + "params":{"protocolVersion":"2025-11-25","capabilities":{}, "clientInfo":{"name":"contract-test","version":"0.1.0"}},"id":"init-1"} """)) .andExpect(status().isOk()) .andExpect(header().exists(McpController.MCP_SESSION_ID_HEADER)) - .andExpect(jsonPath("$.result.protocolVersion").value("2025-06-18")) + .andExpect(jsonPath("$.result.protocolVersion").value("2025-11-25")) .andExpect(jsonPath("$.id").value("init-1")); } + + @Test + void fixedEndpointPathRejectsAdditionalDynamicRouteSegment() throws Exception { + mockMvc + .perform( + post("/mcp/core/external") + .contentType(MediaType.APPLICATION_JSON) + .header("MCP-Protocol-Version", "2025-11-25") + .content(""" + {"jsonrpc":"2.0","method":"tools/list","params":{},"id":"list-1"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.error.code").value(-32600)) + .andExpect(jsonPath("$.error.data.details").value("route key is not allowed for fixed endpoint path")); + } @Test void fixedRootPathIsNotAnAliasForTheConfiguredEndpoint() throws Exception { mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}")) diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java index 2d0b1d3..2bccbaa 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java @@ -5,20 +5,18 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.context; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import com.fasterxml.jackson.databind.node.TextNode; import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; - import java.util.Set; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.RestControllerAdvice; -import tools.jackson.databind.node.StringNode; class McpExceptionHandlerTest { @@ -45,7 +43,7 @@ class McpExceptionHandlerTest { new JsonRpcException( JsonRpcErrorCode.INVALID_PARAMS, "customerNo is required", - StringNode.valueOf("req-1"), + TextNode.valueOf("req-1"), null); var entity = handler.handleJsonRpcException(exception); @@ -57,7 +55,7 @@ class McpExceptionHandlerTest { .isEqualTo("Invalid params: customerNo is required"); assertThat(entity.getBody().error().data().toString()) .contains("guid-1", "customerNo is required"); - assertThat(entity.getBody().id().asString()).isEqualTo("req-1"); + assertThat(entity.getBody().id().asText()).isEqualTo("req-1"); } @Test @@ -73,11 +71,11 @@ class McpExceptionHandlerTest { var json = OBJECT_MAPPER.readTree(OBJECT_MAPPER.writeValueAsString(entity.getBody())); assertThat(entity.getStatusCode().value()).isEqualTo(200); - assertThat(json.path("jsonrpc").asString()).isEqualTo("2.0"); + assertThat(json.path("jsonrpc").asText()).isEqualTo("2.0"); assertThat(json.path("id").asInt()).isEqualTo(3); assertThat(json.has("result")).isFalse(); assertThat(json.path("error").path("code").asInt()).isEqualTo(-32602); - assertThat(json.path("error").path("message").asString()) + assertThat(json.path("error").path("message").asText()) .isEqualTo("Invalid params: 'query' is required"); } 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 9a86026..243665f 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 @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.shinhanlife.dap.biz.mcp.config.McpProperties; +import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder; import io.shinhanlife.dap.biz.mcp.observability.TraceLogger; import java.io.IOException; @@ -24,7 +25,7 @@ class McpExchangeFilterTest { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63"); request.addHeader("x-request-id", "req-100"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.setContent( """ {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} @@ -62,7 +63,7 @@ class McpExchangeFilterTest { void treatsEveryCallerHeaderAsOptionalAndStillCorrelates() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.setContent( """ {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} @@ -79,6 +80,29 @@ class McpExchangeFilterTest { assertThat(response.getHeader("x-request-id")).isNotBlank(); } + @Test + void extractsRouteKeyFromDynamicMcpPath() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/external"); + 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, + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().routeKey()).isEqualTo("external"); + wrappedResponse.setContentType("application/json"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + } + /** * 암호화된 사원번호에 개행이 섞이면 downstream 요청 헤더를 조작할 수 있으므로 입력 경계에서 거부합니다. MCP는 값을 해석하지 않지만 그대로 bypass하기 때문에 이 검증이 유일한 방어선입니다. */ @@ -86,7 +110,7 @@ class McpExchangeFilterTest { void rejectsEmployeeNumberContainingHeaderInjection() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.addHeader("employee-no", "abc\r\nx-injected: evil"); request.setContent( """ @@ -113,7 +137,7 @@ class McpExchangeFilterTest { void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.addHeader("employee-no", " ENC(employee-1) "); request.setContent( """ @@ -140,7 +164,7 @@ class McpExchangeFilterTest { void rejectsGuidThatIsNotUuid() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.addHeader("guid", "guid-1"); request.setContent( """ @@ -198,6 +222,7 @@ class McpExchangeFilterTest { new McpProperties.Trace(true, 8), base.protocol(), base.discovery(), + base.portal(), base.bundles()); McpExchangeFilter filter = filter(limited); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); @@ -244,7 +269,7 @@ class McpExchangeFilterTest { void acceptsInitializedNotificationWithProtocolAndSessionHeaders() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.addHeader(McpController.MCP_SESSION_ID_HEADER, "1868a90c-0e2f-4b5c-9f11-3a7d2c8e5b04"); request.setContent( """ @@ -262,6 +287,43 @@ class McpExchangeFilterTest { assertThat(response.getStatus()).isEqualTo(202); } + + @Test + void rejectsMissingRouteKeyWhenPortalModeIsEnabled() 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()); + McpExchangeFilter filter = filter(portalEnabled); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + 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 without route key"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is required"); + } /** * Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다. */ @@ -269,7 +331,7 @@ class McpExchangeFilterTest { void recordsUndeliverableResponseWhenTheCallerHasAlreadyDisconnected() { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("MCP-Protocol-Version", "2025-06-18"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63"); request.setContent( """ diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java index eb7dd78..450b969 100644 --- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java +++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java @@ -24,7 +24,7 @@ class McpProtocolVersionValidatorTest { @Test void acceptsConfiguredVersionForPostInitializeRequest() { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18"); + request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-11-25"); assertThatCode(() -> validator.validatePostInitializeRequest(request, "tools/list")) .doesNotThrowAnyException(); @@ -33,7 +33,7 @@ class McpProtocolVersionValidatorTest { @Test void acceptsConfiguredVersionForInitializedNotification() { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18"); + request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-11-25"); assertThatCode( () -> validator.validatePostInitializeRequest(request, "notifications/initialized"))