Update project functionality and configuration
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
package io.shinhanlife.dap.biz.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import java.util.Map;
|
||||
|
||||
public final class TestFixtures {
|
||||
|
||||
@@ -28,21 +27,23 @@ public final class TestFixtures {
|
||||
new McpProperties.Server("shl-axhub-mcp-server", "SHL AX HUB MCP Server", "1.0.0"),
|
||||
new McpProperties.Registry(
|
||||
"file:./config/local-core-tools-manifest-sample-v1.json", 30, 5),
|
||||
new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization),
|
||||
new McpProperties.Redis(redisEnabled, "test:mcp:tools"),
|
||||
new McpProperties.ToolClient(1_000, 5_000, 300_000, forwardAuthorization, "tool-server-key"),
|
||||
new McpProperties.Redis(redisEnabled, "test:mcp:tools", "test:mcp:portal-registry"),
|
||||
new McpProperties.Trace(true, 1_048_576),
|
||||
new McpProperties.Protocol(List.of("2025-06-18"), "2025-06-18"),
|
||||
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
|
||||
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
||||
new McpProperties.Portal(false, "", "", 300),
|
||||
bundles);
|
||||
}
|
||||
|
||||
public static McpProperties.Bundle bundle(
|
||||
String id, String manifestUrl, String baseEndpoint, String namePrefix) {
|
||||
return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null);
|
||||
return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null, Map.of());
|
||||
}
|
||||
|
||||
public static McpRequestContext context() {
|
||||
return new McpRequestContext(
|
||||
"external",
|
||||
"req-1",
|
||||
"guid-1",
|
||||
"session-1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -67,6 +68,7 @@ class McpBundleConfigurationTest {
|
||||
null,
|
||||
null,
|
||||
new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
||||
new McpProperties.Portal(false, "", "", 300),
|
||||
List.of());
|
||||
|
||||
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();
|
||||
@@ -81,7 +83,8 @@ class McpBundleConfigurationTest {
|
||||
"http://tool/mcp",
|
||||
"disabled.",
|
||||
false,
|
||||
null);
|
||||
null,
|
||||
Map.of());
|
||||
McpProperties properties = properties(false, false, List.of(disabled));
|
||||
|
||||
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();
|
||||
@@ -90,7 +93,7 @@ class McpBundleConfigurationTest {
|
||||
@Test
|
||||
void treatsAMissingBundleListAsEmpty() {
|
||||
McpProperties properties =
|
||||
new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null);
|
||||
new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(properties.bundles()).isEmpty();
|
||||
assertThat(properties.enabledBundles()).isEmpty();
|
||||
|
||||
@@ -8,7 +8,9 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolArgumentValidator;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
|
||||
@@ -21,17 +23,13 @@ import io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler;
|
||||
import io.shinhanlife.dap.biz.mcp.method.ToolsListHandler;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* `docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/`의 공개 계약 예제를 실제 handler 출력과 대조하는 golden 계약 테스트입니다. 예제 JSON을 테스트가 직접 읽으므로 문서와 코드가 조용히 어긋나면 실패합니다.
|
||||
@@ -75,9 +73,9 @@ class AgentBuilderContractExampleTest {
|
||||
for (JsonNode tool : golden.path("result").path("tools")) {
|
||||
registryTools.add(
|
||||
new ToolMetadata(
|
||||
tool.path("name").asString(),
|
||||
tool.path("name").asText(),
|
||||
"1.0.0",
|
||||
tool.path("description").asString(),
|
||||
tool.path("description").asText(),
|
||||
"https://tool.example/mcp",
|
||||
tool.get("inputSchema"),
|
||||
3_000,
|
||||
@@ -85,7 +83,7 @@ class AgentBuilderContractExampleTest {
|
||||
tool));
|
||||
}
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools()).thenReturn(registryTools);
|
||||
when(registryService.listTools(context().routeKey())).thenReturn(registryTools);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id"));
|
||||
|
||||
@@ -111,13 +109,13 @@ class AgentBuilderContractExampleTest {
|
||||
"{\"endpoint\":\"https://internal.example/mcp\",\"timeoutMillis\":3000}"));
|
||||
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools())
|
||||
when(registryService.listTools(context().routeKey()))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new ToolMetadata(
|
||||
first.path("name").asString(),
|
||||
first.path("name").asText(),
|
||||
"1.0.0",
|
||||
first.path("description").asString(),
|
||||
first.path("description").asText(),
|
||||
"https://tool.example/mcp",
|
||||
first.get("inputSchema"),
|
||||
3_000,
|
||||
@@ -145,11 +143,11 @@ class AgentBuilderContractExampleTest {
|
||||
when(service.execute(any(), any()))
|
||||
.thenReturn(
|
||||
new ToolExecutionService.Result(
|
||||
OBJECT_MAPPER.getNodeFactory().stringNode(goldenContent.path("text").asString()),
|
||||
OBJECT_MAPPER.getNodeFactory().textNode(goldenContent.path("text").asText()),
|
||||
goldenContent.path("_meta").path("searchTime").asDouble()));
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
requestExample.path("method").asString(),
|
||||
requestExample.path("method").asText(),
|
||||
requestExample.get("params"),
|
||||
requestExample.get("id"));
|
||||
|
||||
@@ -162,7 +160,7 @@ class AgentBuilderContractExampleTest {
|
||||
@Test
|
||||
void toolsCallExecutionErrorResponseMatchesThePublishedExample() throws Exception {
|
||||
JsonNode golden = example("tools-call-execution-error-response.json");
|
||||
String goldenText = golden.path("result").path("content").get(0).path("text").asString();
|
||||
String goldenText = golden.path("result").path("content").get(0).path("text").asText();
|
||||
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
when(service.execute(any(), any()))
|
||||
|
||||
@@ -5,11 +5,12 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -18,7 +19,6 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -26,8 +26,6 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 계약 문서의 bundle 예제 JSON을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다. 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다. 조회 대상은 예제 매니페스트를 그대로 돌려주는
|
||||
@@ -94,7 +92,7 @@ class ToolBundleContractExampleTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-06-18에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재
|
||||
* 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-11-25에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재
|
||||
* {@code tools/call}은 {@code content[0].text}만 반환합니다. 예제가 이 규칙을 어기면 Tool 개발자가 예제를 그대로 베껴 표준 위반 매니페스트를 만들게 되므로 계약(§5)을 테스트로 고정합니다.
|
||||
*/
|
||||
@Test
|
||||
@@ -108,7 +106,7 @@ class ToolBundleContractExampleTest {
|
||||
assertThat(tool.has("outputSchema"))
|
||||
.withFailMessage(
|
||||
"운영 매니페스트 예제는 outputSchema를 선언하지 않는다 (v0.2 §5): %s",
|
||||
tool.path("name").asString())
|
||||
tool.path("name").asText())
|
||||
.isFalse());
|
||||
}
|
||||
|
||||
@@ -130,7 +128,7 @@ class ToolBundleContractExampleTest {
|
||||
// 문서 예제와 구현 응답의 field가 어긋나면 운영자가 없는 field를 보고 대시보드를 만들게 된다.
|
||||
assertThat(documented).containsExactlyInAnyOrderElementsOf(implemented);
|
||||
assertThat(example.path("bundles"))
|
||||
.anySatisfy(node -> assertThat(node.path("status").asString()).isEqualTo("disabled"));
|
||||
.anySatisfy(node -> assertThat(node.path("status").asText()).isEqualTo("disabled"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,12 +11,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라
|
||||
@@ -316,8 +314,7 @@ class HelmDeploymentContractTest {
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> loadYaml(Path path) throws IOException {
|
||||
Load load = new Load(LoadSettings.builder().build());
|
||||
Object loaded = load.loadFromString(Files.readString(path));
|
||||
Object loaded = new Yaml().load(Files.readString(path));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
|
||||
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
|
||||
@@ -4,14 +4,18 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,7 +33,7 @@ class ToolExecutionServiceTest {
|
||||
ToolMetadata metadata = tool("http://tool/one");
|
||||
ToolRequest request =
|
||||
new ToolRequest("customer.search", "1.0.0", "http://tool/one", call.arguments(), 3_000);
|
||||
when(registry.findEnabledTool(call.toolName())).thenReturn(metadata);
|
||||
when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
|
||||
when(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"order\":1}")));
|
||||
@@ -54,7 +58,7 @@ class ToolExecutionServiceTest {
|
||||
ToolRequest request =
|
||||
new ToolRequest(
|
||||
"weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000);
|
||||
when(registry.findEnabledTool(call.toolName())).thenReturn(metadata);
|
||||
when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
|
||||
when(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}")));
|
||||
@@ -66,4 +70,60 @@ class ToolExecutionServiceTest {
|
||||
verify(validator).validate(call, metadata);
|
||||
verify(client).execute(request, context());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshesRouteWhenDeletedToolReturnsNotFound() throws Exception {
|
||||
ToolRegistryService registry = mock(ToolRegistryService.class);
|
||||
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
|
||||
ToolRoutingService routing = mock(ToolRoutingService.class);
|
||||
ToolClient client = mock(ToolClient.class);
|
||||
ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
|
||||
ToolMetadata metadata = tool("http://tool/removed");
|
||||
ToolRequest request =
|
||||
new ToolRequest("customer.search", "1.0.0", "http://tool/removed", call.arguments(), 3_000);
|
||||
when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
|
||||
when(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenThrow(new ToolClientException(
|
||||
ToolClientException.Kind.EXECUTION,
|
||||
"Tool returned HTTP 404: customer.search",
|
||||
null,
|
||||
404));
|
||||
ToolExecutionService service =
|
||||
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
|
||||
|
||||
assertThatThrownBy(() -> service.execute(call, context()))
|
||||
.isInstanceOf(JsonRpcException.class)
|
||||
.hasMessageContaining("Tool returned HTTP 404");
|
||||
|
||||
verify(registry).refresh(context().routeKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRefreshRouteForNonStaleToolFailure() throws Exception {
|
||||
ToolRegistryService registry = mock(ToolRegistryService.class);
|
||||
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
|
||||
ToolRoutingService routing = mock(ToolRoutingService.class);
|
||||
ToolClient client = mock(ToolClient.class);
|
||||
ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
|
||||
ToolMetadata metadata = tool("http://tool/error");
|
||||
ToolRequest request =
|
||||
new ToolRequest("customer.search", "1.0.0", "http://tool/error", call.arguments(), 3_000);
|
||||
when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
|
||||
when(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenThrow(new ToolClientException(
|
||||
ToolClientException.Kind.EXECUTION,
|
||||
"Tool returned HTTP 500: customer.search",
|
||||
null,
|
||||
500));
|
||||
ToolExecutionService service =
|
||||
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
|
||||
|
||||
assertThatThrownBy(() -> service.execute(call, context()))
|
||||
.isInstanceOf(JsonRpcException.class)
|
||||
.hasMessageContaining("Tool returned HTTP 500");
|
||||
|
||||
verify(registry, never()).refresh(context().routeKey());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,17 @@ class ToolRoutingServiceTest {
|
||||
assertThat(request.endpoint()).isEqualTo("https://axhub-tool-other.onrender.com/mcp/weather");
|
||||
assertThat(request.arguments()).isNotSameAs(call.arguments());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesExactPortalManagedEndpointWithoutAppendingToolName() throws Exception {
|
||||
ToolMetadata metadata = new ToolMetadata(
|
||||
"business.customer_search", "1.0.0", "search",
|
||||
"http://localhost:9090/internal/tools/customer-search",
|
||||
null, 2_500, true, null, true);
|
||||
ToolCall call = new ToolCall("business.customer_search", OBJECT_MAPPER.readTree("{\"keyword\":\"kim\"}"));
|
||||
|
||||
var request = new ToolRoutingService(properties(false, false)).route(call, metadata);
|
||||
|
||||
assertThat(request.endpoint()).isEqualTo("http://localhost:9090/internal/tools/customer-search");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package io.shinhanlife.dap.biz.mcp.jsonrpc;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class JsonRpcRequestParserTest {
|
||||
|
||||
@@ -27,7 +27,7 @@ class JsonRpcRequestParserTest {
|
||||
"""));
|
||||
|
||||
assertThat(request.method()).isEqualTo("tools/list");
|
||||
assertThat(request.id().asString()).isEqualTo("req-1");
|
||||
assertThat(request.id().asText()).isEqualTo("req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,7 +43,7 @@ class JsonRpcRequestParserTest {
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_REQUEST);
|
||||
assertThat(exception.requestId().asString()).isEqualTo("req-2");
|
||||
assertThat(exception.requestId().asText()).isEqualTo("req-2");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ package io.shinhanlife.dap.biz.mcp.method;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.node.JsonNodeFactory;
|
||||
|
||||
class InitializeHandlerTest {
|
||||
|
||||
@Test
|
||||
void returnsConfiguredInitializeCapabilityAndServerInformation() {
|
||||
void returnsConfiguredInitializeCapabilityAndServerInformation() throws Exception {
|
||||
InitializeHandler handler = new InitializeHandler(properties(false, false));
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
@@ -19,26 +19,45 @@ class InitializeHandlerTest {
|
||||
JsonNodeFactory.instance.objectNode(),
|
||||
JsonNodeFactory.instance.numberNode(1));
|
||||
|
||||
var response = handler.handle(request, null);
|
||||
var response = handler.handle(request, io.shinhanlife.dap.biz.mcp.TestFixtures.context());
|
||||
|
||||
assertThat(response.jsonrpc()).isEqualTo("2.0");
|
||||
assertThat(response.id().asInt()).isEqualTo(1);
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.InitializeResult.class);
|
||||
tools.jackson.databind.JsonNode serialized =
|
||||
com.fasterxml.jackson.databind.JsonNode serialized =
|
||||
io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized)
|
||||
.isEqualTo(
|
||||
io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{
|
||||
"protocolVersion":"2025-06-18",
|
||||
"capabilities":{"tools":{"listChanged":false}},
|
||||
"protocolVersion":"2025-11-25",
|
||||
"capabilities":{"tools":{"listChanged":true}},
|
||||
"serverInfo":{
|
||||
"name":"shl-axhub-mcp-server",
|
||||
"title":"SHL AX HUB MCP Server",
|
||||
"name":"shl-axhub-mcp-server-external",
|
||||
"title":"SHL AX HUB MCP Server (EXTERNAL)",
|
||||
"version":"1.0.0"
|
||||
}
|
||||
}
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsRouteSpecificServerInformationForOth() {
|
||||
InitializeHandler handler = new InitializeHandler(properties(false, false));
|
||||
JsonRpcRequest request = new JsonRpcRequest(
|
||||
"initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(2));
|
||||
var base = io.shinhanlife.dap.biz.mcp.TestFixtures.context();
|
||||
var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext(
|
||||
"oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(),
|
||||
base.virtualEmployeeNo(), base.authorization(), base.deadline());
|
||||
|
||||
var response = handler.handle(request, othContext);
|
||||
var serialized = io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
|
||||
|
||||
assertThat(serialized.path("serverInfo").path("name").asText())
|
||||
.isEqualTo("shl-axhub-mcp-server-oth");
|
||||
assertThat(serialized.path("serverInfo").path("title").asText())
|
||||
.isEqualTo("SHL AX HUB MCP Server (OTH)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package io.shinhanlife.dap.biz.mcp.method;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.node.JsonNodeFactory;
|
||||
|
||||
class InitializedNotificationHandlerTest {
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
|
||||
@@ -17,7 +18,6 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
class ToolsCallHandlerTest {
|
||||
|
||||
@@ -77,7 +77,7 @@ class ToolsCallHandlerTest {
|
||||
.path("content")
|
||||
.get(0)
|
||||
.path("text")
|
||||
.asString())
|
||||
.asText())
|
||||
.isEqualTo(toolResponse);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ import static org.mockito.Mockito.when;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolsListHandlerTest {
|
||||
@@ -20,7 +18,7 @@ class ToolsListHandlerTest {
|
||||
@Test
|
||||
void exposesOnlyMcpToolFieldsAndHidesInternalRegistryMetadata() throws Exception {
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools())
|
||||
when(registryService.listTools(context().routeKey()))
|
||||
.thenReturn(List.of(tool("http://internal-tool.example/search")));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
@@ -29,7 +27,7 @@ class ToolsListHandlerTest {
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class);
|
||||
tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
com.fasterxml.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized)
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
@@ -68,7 +66,7 @@ class ToolsListHandlerTest {
|
||||
3_000,
|
||||
true,
|
||||
publicDefinition);
|
||||
when(registryService.listTools()).thenReturn(List.of(metadata));
|
||||
when(registryService.listTools(context().routeKey())).thenReturn(List.of(metadata));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2"));
|
||||
@@ -81,7 +79,7 @@ class ToolsListHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() {
|
||||
void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() throws Exception {
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
var metadata =
|
||||
new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata(
|
||||
@@ -93,7 +91,7 @@ class ToolsListHandlerTest {
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
when(registryService.listTools()).thenReturn(List.of(metadata));
|
||||
when(registryService.listTools(context().routeKey())).thenReturn(List.of(metadata));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
@@ -103,7 +101,7 @@ class ToolsListHandlerTest {
|
||||
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
com.fasterxml.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized.path("tools").get(0).path("inputSchema"))
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
|
||||
@@ -8,10 +8,8 @@ import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* {@code toolCatalog} health indicator가 어느 probe에 연결되는지 고정하는 계약 테스트입니다.
|
||||
@@ -69,8 +67,7 @@ class HealthGroupContractTest {
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> loadYaml() throws IOException {
|
||||
Load load = new Load(LoadSettings.builder().build());
|
||||
Object loaded = load.loadFromString(Files.readString(APPLICATION_YML));
|
||||
Object loaded = new Yaml().load(Files.readString(APPLICATION_YML));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.health.contributor.Status;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
|
||||
class ToolCatalogHealthIndicatorTest {
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class PortalToolRegistryClientTest {
|
||||
|
||||
private MockWebServer portal;
|
||||
private MockWebServer toolServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
portal = new MockWebServer();
|
||||
portal.start();
|
||||
toolServer = new MockWebServer();
|
||||
toolServer.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
portal.shutdown();
|
||||
toolServer.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshesToolManifestWithoutRefreshingPortalRegistry() {
|
||||
portal.enqueue(portalRegistry("portal-1"));
|
||||
toolServer.enqueue(manifest("manifest-1", "external.weather"));
|
||||
toolServer.enqueue(manifest("manifest-2", "external.exchange"));
|
||||
|
||||
PortalToolRegistryClient client = client();
|
||||
|
||||
Map<String, List<ToolMetadata>> first = client.fetchAllTools();
|
||||
Map<String, List<ToolMetadata>> second = client.fetchAllTools();
|
||||
|
||||
assertThat(first.get("external"))
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("external.weather");
|
||||
assertThat(second.get("external"))
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("external.exchange");
|
||||
assertThat(portal.getRequestCount()).isEqualTo(1);
|
||||
assertThat(toolServer.getRequestCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsMemoryEndpointSnapshotWhenPortalRegistryRefreshFails() {
|
||||
portal.enqueue(portalRegistry("portal-1"));
|
||||
portal.enqueue(new MockResponse().setResponseCode(503));
|
||||
toolServer.enqueue(manifest("manifest-1", "external.weather"));
|
||||
RedisPortalRegistryCache redis = mock(RedisPortalRegistryCache.class);
|
||||
PortalToolRegistryClient client = client(Optional.of(redis));
|
||||
|
||||
client.fetchAllTools();
|
||||
boolean changed = client.refreshSourceRegistry();
|
||||
|
||||
assertThat(changed).isFalse();
|
||||
assertThat(portal.getRequestCount()).isEqualTo(2);
|
||||
verify(redis, never()).loadRegistry();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsEndpointRegistryFromRedisWhenPortalFailsOnColdStart() throws Exception {
|
||||
portal.enqueue(new MockResponse().setResponseCode(503));
|
||||
toolServer.enqueue(manifest("manifest-1", "external.weather"));
|
||||
RedisPortalRegistryCache redis = mock(RedisPortalRegistryCache.class);
|
||||
when(redis.loadRegistry()).thenReturn(Optional.of(OBJECT_MAPPER.readTree(portalRegistryJson("redis-1"))));
|
||||
when(redis.key()).thenReturn("test:mcp:portal-registry");
|
||||
PortalToolRegistryClient client = client(Optional.of(redis));
|
||||
|
||||
Map<String, List<ToolMetadata>> snapshots = client.fetchAllTools();
|
||||
|
||||
assertThat(snapshots.get("external"))
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("external.weather");
|
||||
verify(redis).loadRegistry();
|
||||
assertThat(portal.getRequestCount()).isEqualTo(1);
|
||||
assertThat(toolServer.getRequestCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() {
|
||||
PortalToolRegistryClient client = client();
|
||||
|
||||
assertThatThrownBy(() -> client.fetchTools(""))
|
||||
.hasMessageContaining("Portal registry routeKey is required");
|
||||
assertThat(portal.getRequestCount()).isZero();
|
||||
assertThat(toolServer.getRequestCount()).isZero();
|
||||
}
|
||||
|
||||
private PortalToolRegistryClient client() {
|
||||
return client(Optional.empty());
|
||||
}
|
||||
|
||||
private PortalToolRegistryClient client(Optional<RedisPortalRegistryCache> redisPortalRegistryCache) {
|
||||
RestClient restClient = RestClient.builder()
|
||||
.requestFactory(new SimpleClientHttpRequestFactory())
|
||||
.build();
|
||||
McpProperties base = properties(false, false);
|
||||
McpProperties mcpProperties = new McpProperties(
|
||||
base.identity(),
|
||||
base.endpointPath(),
|
||||
base.server(),
|
||||
base.registry(),
|
||||
base.toolClient(),
|
||||
base.redis(),
|
||||
base.trace(),
|
||||
base.protocol(),
|
||||
base.discovery(),
|
||||
new McpProperties.Portal(true, "", portal.url("/api/portal/registry").toString(), 15),
|
||||
List.of());
|
||||
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
|
||||
return new PortalToolRegistryClient(restClient, mcpProperties, discovery, redisPortalRegistryCache);
|
||||
}
|
||||
|
||||
private MockResponse portalRegistry(String revision) {
|
||||
return jsonResponse(portalRegistryJson(revision));
|
||||
}
|
||||
|
||||
private String portalRegistryJson(String revision) {
|
||||
return """
|
||||
{
|
||||
"registryRevision": "%s",
|
||||
"routes": [
|
||||
{
|
||||
"routeKey": "external",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "external-tool-server",
|
||||
"serviceDomain": "%s",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"executeBasePath": "/tools",
|
||||
"namePrefix": "external.",
|
||||
"status": "ACTIVE",
|
||||
"toolEndpoints": {
|
||||
"external.weather": "/weather",
|
||||
"external.exchange": "/exchange"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
.formatted(revision, toolServer.url("").toString().replaceAll("/+$", ""));
|
||||
}
|
||||
|
||||
private MockResponse manifest(String revision, String toolName) {
|
||||
return jsonResponse(
|
||||
"""
|
||||
{
|
||||
"bundleId": "external-tool-server",
|
||||
"revision": "%s",
|
||||
"tools": [
|
||||
{
|
||||
"name": "%s",
|
||||
"description": "test tool",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"version": "1.0.0",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
.formatted(revision, toolName));
|
||||
}
|
||||
|
||||
private MockResponse jsonResponse(String body) {
|
||||
return new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,16 @@ class RedisToolRegistryCacheTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void keepsDifferentRoutesInDifferentRedisKeys() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
RedisToolRegistryCache cache =
|
||||
new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false));
|
||||
|
||||
assertThat(cache.key("external")).isNotEqualTo(cache.key("business"));
|
||||
assertThat(cache.key("external")).startsWith("test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":route:");
|
||||
}
|
||||
@Test
|
||||
void namespacesKeyByMcpIdentityAndCacheSchemaVersion() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
@@ -56,6 +66,6 @@ class RedisToolRegistryCacheTest {
|
||||
// 캐시 구조가 바뀐 버전이 옛 데이터를 읽어 오염되지 않아야 한다.
|
||||
assertThat(cache.key())
|
||||
.isEqualTo(
|
||||
"test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":all");
|
||||
"test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":route:_default");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
@@ -111,7 +112,8 @@ class ToolBundleDiscoveryTest {
|
||||
"http://tool-core/mcp",
|
||||
"core.",
|
||||
true,
|
||||
"file:./config/local-core-tools-manifest-sample-v1.json"));
|
||||
"file:./config/local-core-tools-manifest-sample-v1.json",
|
||||
Map.of()));
|
||||
|
||||
assertThat(client(properties).fetchTools())
|
||||
.extracting(ToolMetadata::name)
|
||||
@@ -334,6 +336,7 @@ class ToolBundleDiscoveryTest {
|
||||
base.protocol(),
|
||||
new McpProperties.Discovery(
|
||||
true, 1_000, 3_000, 100, maxToolsTotal, maxManifestBytes, 30_000),
|
||||
base.portal(),
|
||||
bundles);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolRegistryRefreshSchedulerTest {
|
||||
|
||||
@Test
|
||||
void refreshesManifestImmediatelyWhenPortalRegistryChanges() {
|
||||
ToolRegistryService service = mock(ToolRegistryService.class);
|
||||
when(service.refreshSourceRegistry()).thenReturn(true);
|
||||
ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service);
|
||||
|
||||
scheduler.scheduledPortalRefresh();
|
||||
|
||||
verify(service).refreshSourceRegistry();
|
||||
verify(service).refreshKnownRoutes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsManifestScheduleSeparateWhenPortalRegistryIsUnchanged() {
|
||||
ToolRegistryService service = mock(ToolRegistryService.class);
|
||||
when(service.refreshSourceRegistry()).thenReturn(false);
|
||||
ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service);
|
||||
|
||||
scheduler.scheduledPortalRefresh();
|
||||
|
||||
verify(service).refreshSourceRegistry();
|
||||
verify(service, never()).refreshKnownRoutes();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -14,14 +15,15 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
class ToolRegistryServiceTest {
|
||||
|
||||
@@ -29,7 +31,7 @@ class ToolRegistryServiceTest {
|
||||
void usesMemorySnapshotWithoutTouchingRedisOrSourceOnTheRequestPath() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool")));
|
||||
when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
service.refresh();
|
||||
clearInvocations(client, redis);
|
||||
@@ -43,7 +45,7 @@ class ToolRegistryServiceTest {
|
||||
void keepsPreviousSnapshotWhenSourceRefreshFails() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools())
|
||||
when(client.fetchTools(""))
|
||||
.thenReturn(List.of(tool("http://memory-tool")))
|
||||
.thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "source down"));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
@@ -53,32 +55,48 @@ class ToolRegistryServiceTest {
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://memory-tool");
|
||||
verify(redis, never()).loadSnapshot();
|
||||
verify(redis, never()).loadSnapshot("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adoptsSharedSnapshotWhenFirstSourceFetchFails() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools())
|
||||
when(client.fetchTools(""))
|
||||
.thenThrow(
|
||||
new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down"));
|
||||
when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool"))));
|
||||
when(redis.loadSnapshot("")).thenReturn(Optional.of(List.of(tool("http://shared-tool"))));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
assertThat(service.refresh())
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://shared-tool");
|
||||
verify(redis, never()).saveSnapshot(any());
|
||||
verify(redis, never()).saveSnapshot(eq(""), any());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void usesRouteSpecificSharedSnapshotWhenRouteRefreshFails() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools("external"))
|
||||
.thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down"));
|
||||
when(redis.loadSnapshot("external")).thenReturn(Optional.of(List.of(tool("http://external-shared-tool"))));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
assertThat(service.refresh("external"))
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://external-shared-tool");
|
||||
verify(redis).loadSnapshot("external");
|
||||
}
|
||||
@Test
|
||||
void sharesOneSourceFetchAcrossConcurrentRefreshCalls() throws Exception {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(client.fetchTools())
|
||||
when(client.fetchTools(""))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
entered.countDown();
|
||||
@@ -88,25 +106,25 @@ class ToolRegistryServiceTest {
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var first = executor.submit(service::refresh);
|
||||
var first = executor.submit(() -> service.refresh());
|
||||
assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
var second = executor.submit(service::refresh);
|
||||
var second = executor.submit(() -> service.refresh());
|
||||
release.countDown();
|
||||
|
||||
assertThat(first.get(5, TimeUnit.SECONDS)).hasSize(1);
|
||||
assertThat(second.get(5, TimeUnit.SECONDS)).hasSize(1);
|
||||
}
|
||||
verify(client, times(1)).fetchTools();
|
||||
verify(client, times(1)).fetchTools("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesSourceFailureWhenNoSnapshotExists() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools())
|
||||
when(client.fetchTools(""))
|
||||
.thenThrow(
|
||||
new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down"));
|
||||
when(redis.loadSnapshot()).thenReturn(Optional.empty());
|
||||
when(redis.loadSnapshot("")).thenReturn(Optional.empty());
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
assertThatThrownBy(service::refresh)
|
||||
@@ -121,7 +139,7 @@ class ToolRegistryServiceTest {
|
||||
void warmStartsFromSharedCacheOnlyBeforeMemoryIsLoaded() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool"))));
|
||||
when(redis.loadSnapshot("")).thenReturn(Optional.of(List.of(tool("http://shared-tool"))));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
service.warmStartFromSharedCache();
|
||||
@@ -131,7 +149,7 @@ class ToolRegistryServiceTest {
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://shared-tool");
|
||||
verify(redis, times(1)).loadSnapshot();
|
||||
verify(redis, times(1)).loadSnapshot("");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@@ -139,19 +157,19 @@ class ToolRegistryServiceTest {
|
||||
void writesSharedCacheOnlyAfterSuccessfulSourceFetch() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools()).thenReturn(List.of(tool("http://direct-tool")));
|
||||
when(client.fetchTools("")).thenReturn(List.of(tool("http://direct-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
service.refresh();
|
||||
|
||||
verify(redis).saveSnapshot(any());
|
||||
verify(redis, never()).loadSnapshot();
|
||||
verify(redis).saveSnapshot(eq(""), any());
|
||||
verify(redis, never()).loadSnapshot("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsASuccessfulEmptyCatalogAsAUsableSnapshot() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
when(client.fetchTools()).thenReturn(List.of());
|
||||
when(client.fetchTools("")).thenReturn(List.of());
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
service.refresh();
|
||||
@@ -163,9 +181,72 @@ class ToolRegistryServiceTest {
|
||||
@Test
|
||||
void resolvesEnabledToolByItsStandardName() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool")));
|
||||
when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
assertThat(service.findEnabledTool("customer.search").version()).isEqualTo("1.0.0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsIndependentSnapshotsPerRoute() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
when(client.fetchTools("external")).thenReturn(List.of(tool("http://external-tool")));
|
||||
when(client.fetchTools("sms")).thenReturn(List.of(tool("http://sms-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
assertThat(service.listTools("external"))
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://external-tool");
|
||||
assertThat(service.listTools("sms"))
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://sms-tool");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshesAllPortalRoutesFromOneAggregateRegistrySnapshot() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
when(client.fetchAllTools()).thenReturn(Map.of(
|
||||
"external", List.of(tool("http://external-tool")),
|
||||
"business", List.of(tool("http://business-tool"))));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
service.refreshKnownRoutes();
|
||||
|
||||
assertThat(service.listTools("external"))
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://external-tool");
|
||||
assertThat(service.listTools("business"))
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://business-tool");
|
||||
verify(client, never()).fetchTools("external");
|
||||
verify(client, never()).fetchTools("business");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishesToolsListChangedOnlyWhenExistingRouteSnapshotChanges() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
|
||||
when(client.fetchTools("external"))
|
||||
.thenReturn(List.of(tool("http://first-tool")))
|
||||
.thenReturn(List.of(tool("http://first-tool")))
|
||||
.thenReturn(List.of(tool("http://second-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty(), publisher);
|
||||
|
||||
service.refresh("external");
|
||||
service.refresh("external");
|
||||
verifyNoInteractions(publisher);
|
||||
|
||||
service.refresh("external");
|
||||
|
||||
ArgumentCaptor<ToolListChangedEvent> eventCaptor =
|
||||
ArgumentCaptor.forClass(ToolListChangedEvent.class);
|
||||
verify(publisher).publishEvent(eventCaptor.capture());
|
||||
assertThat(eventCaptor.getValue().routeKey()).isEqualTo("external");
|
||||
assertThat(eventCaptor.getValue().notification().method())
|
||||
.isEqualTo("notifications/tools/list_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
@@ -51,7 +52,7 @@ class HttpToolClientTest {
|
||||
|
||||
ToolResponse response = client.execute(request, context());
|
||||
|
||||
assertThat(response.data().path("customerName").asString()).isEqualTo("홍길동");
|
||||
assertThat(response.data().path("customerName").asText()).isEqualTo("홍길동");
|
||||
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
|
||||
assertThat(recorded).isNotNull();
|
||||
assertThat(recorded.getMethod()).isEqualTo("POST");
|
||||
@@ -61,9 +62,35 @@ class HttpToolClientTest {
|
||||
assertThat(recorded.getHeader("mcp-session-id")).isEqualTo("session-1");
|
||||
assertThat(recorded.getHeader("employee-no")).isEqualTo("ENC(employee-1)");
|
||||
assertThat(recorded.getHeader("virtual-employee-no")).isEqualTo("ENC(virtual-1)");
|
||||
assertThat(recorded.getHeader("X-Tool-Server-API-Key")).isEqualTo("tool-server-key");
|
||||
assertThat(recorded.getHeader("x-trace-id")).isNull();
|
||||
assertThat(recorded.getHeader("Authorization")).isNull();
|
||||
assertThat(recorded.getBody().readUtf8()).contains("1234567890");
|
||||
assertThat(recorded.getBody().readUtf8())
|
||||
.contains("customerNo", "1234567890")
|
||||
.doesNotContain("arguments");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postsDirectArgumentsForEveryToolServer() throws Exception {
|
||||
server.enqueue(new MockResponse().setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"quote\":\"시작이 반이다.\"}"));
|
||||
HttpToolClient client =
|
||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
||||
ToolRequest request = new ToolRequest(
|
||||
"smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(),
|
||||
OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000);
|
||||
var base = context();
|
||||
var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext(
|
||||
"oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(),
|
||||
base.virtualEmployeeNo(), base.authorization(), Instant.now().plusSeconds(10));
|
||||
|
||||
client.execute(request, othContext);
|
||||
|
||||
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
|
||||
assertThat(recorded).isNotNull();
|
||||
assertThat(recorded.getBody().readUtf8())
|
||||
.contains("category", "속담")
|
||||
.doesNotContain("arguments");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +108,27 @@ class HttpToolClientTest {
|
||||
|
||||
ToolResponse response = client.execute(request, context());
|
||||
|
||||
assertThat(response.data().isString()).isTrue();
|
||||
assertThat(response.data().asString()).isEqualTo("123");
|
||||
assertThat(response.data().isTextual()).isTrue();
|
||||
assertThat(response.data().asText()).isEqualTo("123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesHttpStatusOnToolError() throws Exception {
|
||||
server.enqueue(new MockResponse().setResponseCode(410).setBody("gone"));
|
||||
HttpToolClient client =
|
||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
||||
ToolRequest request =
|
||||
new ToolRequest(
|
||||
"customer.search",
|
||||
"1.0.0",
|
||||
server.url("/api/v1/search").toString(),
|
||||
OBJECT_MAPPER.readTree("{\"customerNo\":\"1234567890\"}"),
|
||||
3_000);
|
||||
|
||||
assertThatThrownBy(() -> client.execute(request, context()))
|
||||
.isInstanceOfSatisfying(ToolClientException.class, exception -> {
|
||||
assertThat(exception.kind()).isEqualTo(ToolClientException.Kind.EXECUTION);
|
||||
assertThat(exception.httpStatusCode()).hasValue(410);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,20 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
|
||||
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry.Handler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import tools.jackson.databind.node.JsonNodeFactory;
|
||||
|
||||
class McpControllerTest {
|
||||
|
||||
@@ -95,7 +93,7 @@ class McpControllerTest {
|
||||
|
||||
PostMapping mapping =
|
||||
McpController.class
|
||||
.getMethod("handleMcpRequest", tools.jackson.databind.JsonNode.class)
|
||||
.getMethod("handleMcpRequest", com.fasterxml.jackson.databind.JsonNode.class)
|
||||
.getAnnotation(PostMapping.class);
|
||||
assertThat(mapping.produces()).contains(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
|
||||
|
||||
@@ -77,7 +77,7 @@ class McpEndpointMethodContractTest {
|
||||
@Test
|
||||
void deleteMcpReturns405SoSessionTerminationIsNotMistakenForSuccess() throws Exception {
|
||||
mockMvc
|
||||
.perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-06-18"))
|
||||
.perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-11-25"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(header().string("Allow", "POST"))
|
||||
.andExpect(content().string(""));
|
||||
@@ -102,15 +102,30 @@ class McpEndpointMethodContractTest {
|
||||
.content(
|
||||
"""
|
||||
{"jsonrpc":"2.0","method":"initialize",
|
||||
"params":{"protocolVersion":"2025-06-18","capabilities":{},
|
||||
"params":{"protocolVersion":"2025-11-25","capabilities":{},
|
||||
"clientInfo":{"name":"contract-test","version":"0.1.0"}},"id":"init-1"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().exists(McpController.MCP_SESSION_ID_HEADER))
|
||||
.andExpect(jsonPath("$.result.protocolVersion").value("2025-06-18"))
|
||||
.andExpect(jsonPath("$.result.protocolVersion").value("2025-11-25"))
|
||||
.andExpect(jsonPath("$.id").value("init-1"));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void fixedEndpointPathRejectsAdditionalDynamicRouteSegment() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/mcp/core/external")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("MCP-Protocol-Version", "2025-11-25")
|
||||
.content("""
|
||||
{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"list-1"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.error.code").value(-32600))
|
||||
.andExpect(jsonPath("$.error.data.details").value("route key is not allowed for fixed endpoint path"));
|
||||
}
|
||||
@Test
|
||||
void fixedRootPathIsNotAnAliasForTheConfiguredEndpoint() throws Exception {
|
||||
mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
|
||||
@@ -5,20 +5,18 @@ import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import tools.jackson.databind.node.StringNode;
|
||||
|
||||
class McpExceptionHandlerTest {
|
||||
|
||||
@@ -45,7 +43,7 @@ class McpExceptionHandlerTest {
|
||||
new JsonRpcException(
|
||||
JsonRpcErrorCode.INVALID_PARAMS,
|
||||
"customerNo is required",
|
||||
StringNode.valueOf("req-1"),
|
||||
TextNode.valueOf("req-1"),
|
||||
null);
|
||||
|
||||
var entity = handler.handleJsonRpcException(exception);
|
||||
@@ -57,7 +55,7 @@ class McpExceptionHandlerTest {
|
||||
.isEqualTo("Invalid params: customerNo is required");
|
||||
assertThat(entity.getBody().error().data().toString())
|
||||
.contains("guid-1", "customerNo is required");
|
||||
assertThat(entity.getBody().id().asString()).isEqualTo("req-1");
|
||||
assertThat(entity.getBody().id().asText()).isEqualTo("req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,11 +71,11 @@ class McpExceptionHandlerTest {
|
||||
var json = OBJECT_MAPPER.readTree(OBJECT_MAPPER.writeValueAsString(entity.getBody()));
|
||||
|
||||
assertThat(entity.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(json.path("jsonrpc").asString()).isEqualTo("2.0");
|
||||
assertThat(json.path("jsonrpc").asText()).isEqualTo("2.0");
|
||||
assertThat(json.path("id").asInt()).isEqualTo(3);
|
||||
assertThat(json.has("result")).isFalse();
|
||||
assertThat(json.path("error").path("code").asInt()).isEqualTo(-32602);
|
||||
assertThat(json.path("error").path("message").asString())
|
||||
assertThat(json.path("error").path("message").asText())
|
||||
.isEqualTo("Invalid params: 'query' is required");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -24,7 +25,7 @@ class McpExchangeFilterTest {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
|
||||
request.addHeader("x-request-id", "req-100");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
@@ -62,7 +63,7 @@ class McpExchangeFilterTest {
|
||||
void treatsEveryCallerHeaderAsOptionalAndStillCorrelates() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
@@ -79,6 +80,29 @@ class McpExchangeFilterTest {
|
||||
assertThat(response.getHeader("x-request-id")).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsRouteKeyFromDynamicMcpPath() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/external");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().routeKey()).isEqualTo("external");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 암호화된 사원번호에 개행이 섞이면 downstream 요청 헤더를 조작할 수 있으므로 입력 경계에서 거부합니다. MCP는 값을 해석하지 않지만 그대로 bypass하기 때문에 이 검증이 유일한 방어선입니다.
|
||||
*/
|
||||
@@ -86,7 +110,7 @@ class McpExchangeFilterTest {
|
||||
void rejectsEmployeeNumberContainingHeaderInjection() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.addHeader("employee-no", "abc\r\nx-injected: evil");
|
||||
request.setContent(
|
||||
"""
|
||||
@@ -113,7 +137,7 @@ class McpExchangeFilterTest {
|
||||
void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.addHeader("employee-no", " ENC(employee-1) ");
|
||||
request.setContent(
|
||||
"""
|
||||
@@ -140,7 +164,7 @@ class McpExchangeFilterTest {
|
||||
void rejectsGuidThatIsNotUuid() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.addHeader("guid", "guid-1");
|
||||
request.setContent(
|
||||
"""
|
||||
@@ -198,6 +222,7 @@ class McpExchangeFilterTest {
|
||||
new McpProperties.Trace(true, 8),
|
||||
base.protocol(),
|
||||
base.discovery(),
|
||||
base.portal(),
|
||||
base.bundles());
|
||||
McpExchangeFilter filter = filter(limited);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
@@ -244,7 +269,7 @@ class McpExchangeFilterTest {
|
||||
void acceptsInitializedNotificationWithProtocolAndSessionHeaders() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.addHeader(McpController.MCP_SESSION_ID_HEADER, "1868a90c-0e2f-4b5c-9f11-3a7d2c8e5b04");
|
||||
request.setContent(
|
||||
"""
|
||||
@@ -262,6 +287,43 @@ class McpExchangeFilterTest {
|
||||
assertThat(response.getStatus()).isEqualTo(202);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void rejectsMissingRouteKeyWhenPortalModeIsEnabled() throws Exception {
|
||||
McpProperties base = properties(false, false);
|
||||
McpProperties portalEnabled =
|
||||
new McpProperties(
|
||||
base.identity(),
|
||||
base.endpointPath(),
|
||||
base.server(),
|
||||
base.registry(),
|
||||
base.toolClient(),
|
||||
base.redis(),
|
||||
base.trace(),
|
||||
base.protocol(),
|
||||
base.discovery(),
|
||||
new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15),
|
||||
base.bundles());
|
||||
McpExchangeFilter filter = filter(portalEnabled);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called without route key");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is required");
|
||||
}
|
||||
/**
|
||||
* Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다.
|
||||
*/
|
||||
@@ -269,7 +331,7 @@ class McpExchangeFilterTest {
|
||||
void recordsUndeliverableResponseWhenTheCallerHasAlreadyDisconnected() {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
|
||||
request.setContent(
|
||||
"""
|
||||
|
||||
@@ -24,7 +24,7 @@ class McpProtocolVersionValidatorTest {
|
||||
@Test
|
||||
void acceptsConfiguredVersionForPostInitializeRequest() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18");
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-11-25");
|
||||
|
||||
assertThatCode(() -> validator.validatePostInitializeRequest(request, "tools/list"))
|
||||
.doesNotThrowAnyException();
|
||||
@@ -33,7 +33,7 @@ class McpProtocolVersionValidatorTest {
|
||||
@Test
|
||||
void acceptsConfiguredVersionForInitializedNotification() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18");
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-11-25");
|
||||
|
||||
assertThatCode(
|
||||
() -> validator.validatePostInitializeRequest(request, "notifications/initialized"))
|
||||
|
||||
Reference in New Issue
Block a user