Support local resource Portal registry loading

This commit is contained in:
2026-08-18 10:08:48 +09:00
parent e86942ddd4
commit 7f431a1c37
6 changed files with 174 additions and 15 deletions

View File

@@ -0,0 +1,78 @@
{
"registryRevision": "local-toolserver-info-sample-v1",
"routes": [
{
"routeKey": "cus",
"toolServices": [
{
"serviceKey": "was-cus",
"displayName": "CUS Tool Server",
"serviceDomain": "https://tool-cus.devjun.net",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "",
"toolEndpoints": {
"ins_insurance_processor": "/mcp/ins_insurance_processor",
"cmm_customer_tool": "/mcp/cmm_customer_tool",
"cmm_template_url": "/mcp/cmm_template_url",
"smp_exchange_inquiry": "/mcp/smp_exchange_inquiry",
"sol_request_detail": "/mcp/sol_request_detail",
"smp_weather_inquiry": "/mcp/smp_weather_inquiry",
"sol_request_list": "/mcp/sol_request_list",
"cmm_comcode_lookup": "/mcp/cmm_comcode_lookup",
"cmm_meta_table": "/mcp/cmm_meta_table",
"smp_quote_daily": "/mcp/smp_quote_daily",
"oth_onnba3011_call": "/mcp/oth_onnba3011_call",
"smp_team_list": "/mcp/smp_team_list"
},
"status": "ACTIVE"
}
]
},
{
"routeKey": "sal",
"toolServices": [
{
"serviceKey": "was-sal",
"displayName": "SAL Tool Server",
"serviceDomain": "https://tool-sal.devjun.net",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "",
"toolEndpoints": {},
"status": "ACTIVE"
}
]
},
{
"routeKey": "pro",
"toolServices": [
{
"serviceKey": "was-pro",
"displayName": "PRO Tool Server",
"serviceDomain": "https://tool-pro.devjun.net",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "",
"toolEndpoints": {},
"status": "ACTIVE"
}
]
},
{
"routeKey": "sys",
"toolServices": [
{
"serviceKey": "was-sys",
"displayName": "SYS Tool Server",
"serviceDomain": "https://tool-sys.devjun.net",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "",
"toolEndpoints": {},
"status": "ACTIVE"
}
]
}
]
}

View File

@@ -196,6 +196,8 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다.
## Portal Registry and Tool manifest refresh
로컬 검증에서는 `mcp.portal.registry-url``file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다.
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server endpoint 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 endpoint 목록을 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 endpoint 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 endpoint 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list``tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory endpoint snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 endpoint 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 endpoint 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 설정된 `base-endpoint`에 요청 name을 path segment로 붙여 `tools/call`을 POST한다.

View File

@@ -1,11 +1,14 @@
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 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.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
@@ -18,13 +21,17 @@ 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.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
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}입니다.
* Portal Registry 응답을 Tool Server endpoint 원천으로 변환하는 adapter입니다.
* MCP 요청을 직접 처리하지 않고 {@link ToolRegistryService}의 기동 preload와 주기 refresh 단계에서 호출되며,
* HTTP(S) Portal API 또는 로컬 리소스 파일을 같은 registry JSON 계약으로 읽은 뒤 기존 {@link ToolBundleDiscovery} 검증 경로에 연결합니다.
* 주요 의존성은 Portal/리소스 조회를 담당하는 {@link RestClient}와 {@link ResourceLoader}, JSON 파서를 제공하는 {@link ObjectMapper},
* Tool Service manifest 검증을 담당하는 {@link ToolBundleDiscovery}, 그리고 portal/discovery 정책을 제공하는 {@link McpProperties}입니다.
*/
@Component
@ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "true")
@@ -36,24 +43,31 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
private final McpProperties properties;
private final ToolBundleDiscovery discovery;
private final Optional<RedisPortalRegistryCache> redisPortalRegistryCache;
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
private final java.util.concurrent.atomic.AtomicReference<String> lastPortalRevision =
new java.util.concurrent.atomic.AtomicReference<>();
private final java.util.concurrent.ConcurrentMap<String, List<Bundle>> bundlesByRoute =
new java.util.concurrent.ConcurrentHashMap<>();
/**
* Portal Registry 조회 client와 기존 Tool Service manifest discovery를 주입받습니다.
* 포털 응답은 이 adapter에서만 실행 주소 정보로 변환하고, 실제 manifest 검증은 기존 discovery 계약을 재사용합니다.
* Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다.
* registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어
* 동일한 endpoint snapshot 변환 경로에 전달합니다.
*/
public PortalToolRegistryClient(
@Qualifier("manifestRestClient") RestClient restClient,
McpProperties properties,
ToolBundleDiscovery discovery,
Optional<RedisPortalRegistryCache> redisPortalRegistryCache) {
Optional<RedisPortalRegistryCache> redisPortalRegistryCache,
ObjectMapper objectMapper,
ResourceLoader resourceLoader) {
this.restClient = restClient;
this.properties = properties;
this.discovery = discovery;
this.redisPortalRegistryCache = redisPortalRegistryCache;
this.objectMapper = objectMapper;
this.resourceLoader = resourceLoader;
}
/**
@@ -144,20 +158,55 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
}
/**
* 포털 registry URL을 호출하고 기본 응답 shape를 검증합니다.
* 원문 payload를 오류 메시지에 포함하지 않고, 호출 실패는 Registry unavailable 예외로 상위 refresh 정책에 전달합니다.
* 설정된 registry 위치에서 route별 Tool Server endpoint registry JSON을 읽고 기본 응답 shape를 검증합니다.
* {@code http:}/{@code https:} 위치는 Portal API를 호출하고, {@code file:}/{@code classpath:} 위치는 로컬 리소스를 읽어
* 개발 환경에서 Portal 서버 없이도 같은 snapshot 갱신 흐름을 검증할 수 있게 합니다.
*/
private JsonNode portalRegistry(String registryUrl) {
JsonNode registry = restClient.get()
.uri(registryUrl)
.retrieve()
.body(JsonNode.class);
JsonNode registry = isResourceRegistry(registryUrl)
? portalRegistryResource(registryUrl)
: portalRegistryHttp(registryUrl);
if (registry == null) {
throw unavailable("Portal registry response is invalid");
}
return registry;
}
/**
* Portal Registry HTTP API를 호출해 registry JSON을 가져옵니다.
* 호출 실패는 상위 refresh fallback 정책이 판단할 수 있도록 그대로 예외로 전달합니다.
*/
private JsonNode portalRegistryHttp(String registryUrl) {
return restClient.get()
.uri(registryUrl)
.retrieve()
.body(JsonNode.class);
}
/**
* 로컬 파일 또는 classpath resource에서 registry JSON을 읽습니다.
* 파일이 없거나 JSON 파싱이 실패하면 endpoint 원천을 사용할 수 없는 상태로 변환해 기존 memory/Redis fallback 규칙을 유지합니다.
*/
private JsonNode portalRegistryResource(String registryLocation) {
Resource resource = resourceLoader.getResource(registryLocation);
if (!resource.exists()) {
throw unavailable("Portal registry resource does not exist: " + registryLocation);
}
try (InputStream input = resource.getInputStream()) {
return objectMapper.readTree(input);
} catch (IOException exception) {
throw unavailable("Portal registry resource is unreadable: " + registryLocation);
}
}
/**
* registry 위치가 네트워크 Portal API인지 Spring resource location인지 구분합니다.
* 현재 로컬 검증 용도는 {@code file:}과 {@code classpath:}만 허용해 실수로 HTTP URL을 resource로 해석하지 않습니다.
*/
private boolean isResourceRegistry(String registryLocation) {
return registryLocation.startsWith("file:") || registryLocation.startsWith("classpath:");
}
/**
* 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다.
* 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다.

View File

@@ -5,7 +5,7 @@ mcp:
enabled: false
portal:
enabled: true
registry-url: http://localhost:7070/api/portal/registry
registry-url: file:./config/local-toolserver-info-sample-v1.json
refresh-interval-seconds: 15
bundles: []
redis:

View File

@@ -86,6 +86,7 @@ mcp:
portal:
enabled: ${MCP_PORTAL_ENABLED:false}
route-key: ${MCP_PORTAL_ROUTE_KEY:}
# HTTP(S) Portal API or local Spring resource location such as file:./config/local-toolserver-info-sample-v1.json.
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:

View File

@@ -10,6 +10,9 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -18,6 +21,8 @@ import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@@ -26,6 +31,9 @@ class PortalToolRegistryClientTest {
private MockWebServer portal;
private MockWebServer toolServer;
@TempDir
private Path tempDir;
@BeforeEach
void setUp() throws Exception {
portal = new MockWebServer();
@@ -96,6 +104,22 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isEqualTo(1);
}
@Test
void loadsEndpointRegistryFromLocalFileWithoutCallingPortal() throws Exception {
Path registryFile = tempDir.resolve("local-toolserver-info-sample-v1.json");
Files.writeString(registryFile, portalRegistryJson("file-1"), StandardCharsets.UTF_8);
toolServer.enqueue(manifest("manifest-1", "external.weather"));
PortalToolRegistryClient client = client(registryFile.toUri().toString(), Optional.empty());
Map<String, List<ToolMetadata>> snapshots = client.fetchAllTools();
assertThat(snapshots.get("external"))
.extracting(ToolMetadata::name)
.containsExactly("external.weather");
assertThat(portal.getRequestCount()).isZero();
assertThat(toolServer.getRequestCount()).isEqualTo(1);
}
@Test
void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() {
@@ -112,6 +136,10 @@ class PortalToolRegistryClientTest {
}
private PortalToolRegistryClient client(Optional<RedisPortalRegistryCache> redisPortalRegistryCache) {
return client(portal.url("/api/portal/registry").toString(), redisPortalRegistryCache);
}
private PortalToolRegistryClient client(String registryUrl, Optional<RedisPortalRegistryCache> redisPortalRegistryCache) {
RestClient restClient = RestClient.builder()
.requestFactory(new SimpleClientHttpRequestFactory())
.build();
@@ -126,10 +154,11 @@ class PortalToolRegistryClientTest {
base.trace(),
base.protocol(),
base.discovery(),
new McpProperties.Portal(true, "", portal.url("/api/portal/registry").toString(), 15),
new McpProperties.Portal(true, "", registryUrl, 15),
List.of());
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
return new PortalToolRegistryClient(restClient, mcpProperties, discovery, redisPortalRegistryCache);
return new PortalToolRegistryClient(
restClient, mcpProperties, discovery, redisPortalRegistryCache, OBJECT_MAPPER, new DefaultResourceLoader());
}
private MockResponse portalRegistry(String revision) {