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

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