Update project functionality and configuration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String, String> toolEndpoints) {
|
||||
|
||||
public Bundle {
|
||||
toolEndpoints = toolEndpoints == null ? Map.of() : Map.copyOf(toolEndpoints);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.time.Instant;
|
||||
* <b>불투명 값</b>입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다.
|
||||
*/
|
||||
public record McpRequestContext(
|
||||
String routeKey,
|
||||
String requestId,
|
||||
String guid,
|
||||
String mcpSessionId,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, Long> 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 기준 경과 시간을 밀리초 단위로 계산합니다.
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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} 또는
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 헤더나 인증
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 직렬화 설정입니다.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<McpSchema.Tool> tools = registryService.listTools().stream().map(this::toMcpTool).toList();
|
||||
List<McpSchema.Tool> tools = registryService.listTools(context.routeKey()).stream().map(this::toMcpTool).toList();
|
||||
return JsonRpcResponse.success(request.id(), McpSchema.ListToolsResult.builder(tools).build());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ToolMetadata> fetchTools() {
|
||||
public List<ToolMetadata> 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);
|
||||
}
|
||||
|
||||
@@ -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> redisPortalRegistryCache;
|
||||
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 계약을 재사용합니다.
|
||||
*/
|
||||
public PortalToolRegistryClient(
|
||||
@Qualifier("manifestRestClient") RestClient restClient,
|
||||
McpProperties properties,
|
||||
ToolBundleDiscovery discovery,
|
||||
Optional<RedisPortalRegistryCache> 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<ToolMetadata> fetchTools(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
ensurePortalRegistryLoaded();
|
||||
List<Bundle> 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<String, List<ToolMetadata>> fetchAllTools() {
|
||||
ensurePortalRegistryLoaded();
|
||||
Map<String, List<ToolMetadata>> 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<JsonNode> cached = redisPortalRegistryCache.flatMap(RedisPortalRegistryCache::loadRegistry);
|
||||
if (cached.isPresent()) {
|
||||
log.info("Portal registry loaded from Redis fallback. key={}",
|
||||
redisPortalRegistryCache.map(RedisPortalRegistryCache::key).orElse("<unavailable>"));
|
||||
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<String, List<Bundle>> 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<ToolMetadata> fetchRouteTools(String routeKey, List<Bundle> bundles) {
|
||||
List<BundleResult> 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<Bundle> toBundles(JsonNode services) {
|
||||
List<Bundle> 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<String, String> 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<ToolMetadata> merge(List<BundleResult> results) {
|
||||
if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
|
||||
throw unavailable("At least one Portal Tool Service has no usable snapshot");
|
||||
}
|
||||
List<BundleTool> 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<String> names = new HashSet<>();
|
||||
List<ToolMetadata> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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<JsonNode> 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;
|
||||
}
|
||||
}
|
||||
@@ -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입니다. 원천이 아니라 <b>공유 지점</b>이므로 조회 성공 결과만 저장하고, 읽기·쓰기·직렬화 실패는 모두 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<List<ToolMetadata>> loadSnapshot() {
|
||||
return loadSnapshot("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 route의 Tool snapshot을 읽습니다.
|
||||
* key miss, Redis 장애, 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 refresh 정책으로 진행하게 합니다.
|
||||
*/
|
||||
public Optional<List<ToolMetadata>> 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<ToolMetadata> tools) {
|
||||
saveSnapshot("", tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 route의 Tool snapshot을 공유 지점에 저장하고 TTL을 설정합니다.
|
||||
* 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신 성공 여부를 바꾸지 않습니다.
|
||||
*/
|
||||
public void saveSnapshot(String routeKey, List<ToolMetadata> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BundleResult> discoverAll() {
|
||||
List<Bundle> targets = properties.enabledBundles();
|
||||
return discoverAll(properties.enabledBundles());
|
||||
}
|
||||
|
||||
/**
|
||||
* 전달받은 Tool Service bundle 목록을 동시에 조회하고 bundle별 성공본 또는 last-good 결과를 반환합니다.
|
||||
* Portal Registry client가 포털 응답을 임시 bundle 모델로 변환한 뒤 이 메서드를 호출합니다.
|
||||
*/
|
||||
public List<BundleResult> discoverAll(List<Bundle> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ToolMetadata> fetchTools() {
|
||||
public List<ToolMetadata> fetchTools(String routeKey) {
|
||||
List<BundleResult> results = discovery.discoverAll();
|
||||
if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
|
||||
throw new JsonRpcException(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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을 반환합니다.
|
||||
|
||||
@@ -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<ToolMetadata> fetchTools();
|
||||
List<ToolMetadata> fetchTools(String routeKey);
|
||||
|
||||
/**
|
||||
* 원천이 route별 전체 Tool catalog를 한 번에 제공할 수 있으면 route key별 immutable 목록으로 반환합니다.
|
||||
* 지원하지 않는 구현은 빈 Map을 반환하며, 호출자는 기존 단일 route 조회 방식으로 fallback합니다.
|
||||
*/
|
||||
default Map<String, List<ToolMetadata>> fetchAllTools() {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool metadata 조회에 앞서 외부 registry의 endpoint 목록을 갱신합니다.
|
||||
* 포털을 사용하지 않는 구현은 아무 작업도 하지 않으며, 호출자는 실패 시 기존 snapshot을 유지합니다.
|
||||
*/
|
||||
default boolean refreshSourceRegistry() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다.
|
||||
*/
|
||||
default List<ToolMetadata> fetchTools() {
|
||||
return fetchTools("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RedisToolRegistryCache> redisCache;
|
||||
private final AtomicReference<List<ToolMetadata>> snapshot = new AtomicReference<>();
|
||||
private final AtomicReference<CompletableFuture<List<ToolMetadata>>> refreshInFlight =
|
||||
new AtomicReference<>();
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ConcurrentMap<String, List<ToolMetadata>> snapshotsByRoute = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, CompletableFuture<List<ToolMetadata>>> refreshInFlightByRoute =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다.
|
||||
* ?먯쿇 Registry? memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎.
|
||||
*/
|
||||
public ToolRegistryService(
|
||||
ToolRegistryClient registryClient, Optional<RedisToolRegistryCache> 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<RedisToolRegistryCache> redisCache,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this(registryClient, redisCache, eventPublisher, new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* ?먯쿇 Registry, ?좏깮??Redis cache, 蹂寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎.
|
||||
* Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂寃?寃利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲??
|
||||
*/
|
||||
@Autowired
|
||||
public ToolRegistryService(
|
||||
ToolRegistryClient registryClient,
|
||||
Optional<RedisToolRegistryCache> 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<ToolMetadata> listTools() {
|
||||
List<ToolMetadata> memory = snapshot.get();
|
||||
return listTools("");
|
||||
}
|
||||
|
||||
/**
|
||||
* route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎.
|
||||
* ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
|
||||
*/
|
||||
public List<ToolMetadata> listTools(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
List<ToolMetadata> 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<ToolMetadata> 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<ToolMetadata> cached = listTools(normalizedRouteKey);
|
||||
Optional<ToolMetadata> 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<ToolMetadata> refreshed = refresh();
|
||||
List<ToolMetadata> 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<ToolMetadata> refresh() {
|
||||
return refresh("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 吏?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲??
|
||||
* 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
|
||||
*/
|
||||
public List<ToolMetadata> refresh(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
CompletableFuture<List<ToolMetadata>> candidate = new CompletableFuture<>();
|
||||
CompletableFuture<List<ToolMetadata>> running =
|
||||
refreshInFlight.compareAndExchange(null, candidate);
|
||||
refreshInFlightByRoute.putIfAbsent(normalizedRouteKey, candidate);
|
||||
if (running != null) {
|
||||
return awaitRefresh(running);
|
||||
}
|
||||
try {
|
||||
List<ToolMetadata> tools = refreshOnce();
|
||||
List<ToolMetadata> 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<ToolMetadata> refreshOnce() {
|
||||
public void refreshKnownRoutes() {
|
||||
Map<String, List<ToolMetadata>> snapshots = registryClient.fetchAllTools();
|
||||
if (!snapshots.isEmpty()) {
|
||||
snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey));
|
||||
snapshots.forEach(this::replaceSnapshot);
|
||||
return;
|
||||
}
|
||||
List<String> 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<ToolMetadata> refreshOnce(String routeKey) {
|
||||
try {
|
||||
List<ToolMetadata> 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<ToolMetadata> immutableTools = List.copyOf(tools);
|
||||
List<ToolMetadata> 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<ToolMetadata> memory = snapshot.get();
|
||||
List<ToolMetadata> memory = snapshotsByRoute.get(routeKey);
|
||||
if (memory != null) {
|
||||
return memory;
|
||||
}
|
||||
Optional<List<ToolMetadata>> 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<ToolMetadata> tools) {
|
||||
List<ToolMetadata> immutableTools = List.copyOf(tools);
|
||||
List<ToolMetadata> previous = snapshotsByRoute.put(routeKey, immutableTools);
|
||||
logSnapshot(routeKey, previous, immutableTools);
|
||||
publishListChangedIfNeeded(routeKey, previous, immutableTools);
|
||||
redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools));
|
||||
}
|
||||
|
||||
private List<ToolMetadata> awaitRefresh(CompletableFuture<List<ToolMetadata>> refresh) {
|
||||
try {
|
||||
return refresh.join();
|
||||
@@ -151,7 +253,7 @@ public class ToolRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부됩니다.
|
||||
* ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났? ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲??
|
||||
*/
|
||||
private Optional<ToolMetadata> match(List<ToolMetadata> 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<ToolMetadata> previous, List<ToolMetadata> 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<ToolMetadata> previous, List<ToolMetadata> 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<ToolMetadata> current) {
|
||||
Map<String, Object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String, Object> 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<String, Object>) loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, Object> 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<String, Object>) loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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<String, List<ToolMetadata>> first = client.fetchAllTools();
|
||||
Map<String, List<ToolMetadata>> 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<String, List<ToolMetadata>> 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> 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<ToolListChangedEvent> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("{}"))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user