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