Update project functionality and configuration

This commit is contained in:
2026-08-14 17:59:07 +09:00
parent a4eb5a580f
commit 189277a78c
113 changed files with 4838 additions and 348 deletions

View File

@@ -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;

View File

@@ -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);
}
}
/**

View File

@@ -10,6 +10,7 @@ import java.time.Instant;
* <b>불투명 값</b>입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다.
*/
public record McpRequestContext(
String routeKey,
String requestId,
String guid,
String mcpSessionId,

View File

@@ -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();

View File

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

View File

@@ -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 기준 경과 시간을 밀리초 단위로 계산합니다.
*/

View File

@@ -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(),

View File

@@ -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} 또는

View File

@@ -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);
}
}

View File

@@ -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 헤더나 인증

View File

@@ -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");
}

View File

@@ -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 직렬화 설정입니다.

View File

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

View File

@@ -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();
}
/**

View File

@@ -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());
}

View File

@@ -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;
/**

View File

@@ -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);
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
/**

View File

@@ -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(

View File

@@ -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());
}
}

View File

@@ -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을 반환합니다.

View File

@@ -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("");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 snapshotmemory에 적재됐는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 빈 목록도 유효한 전체 상태이므로 {@code null} 여부만 판단하며, readiness 확인 과정에서 RedisTool 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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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:

View File

@@ -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: []

View File

@@ -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",

View File

@@ -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();

View File

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

View File

@@ -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"));
}
/**

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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");
}
}

View File

@@ -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");
});
}
}

View File

@@ -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)");
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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;
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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);
});
}
}

View File

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

View File

@@ -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("{}"))

View File

@@ -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");
}

View File

@@ -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(
"""

View File

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