Support local resource Portal registry loading
This commit is contained in:
@@ -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 전체를 그대로 보여 줍니다.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user