Initial commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package io.shinhanlife.dap.biz.mcp;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class McpServerApplicationTest {
|
||||
|
||||
@MockitoBean
|
||||
private ToolRegistryClient toolRegistryClient;
|
||||
|
||||
@Test
|
||||
void contextLoadsWithoutRedisOrRegistry() {
|
||||
// ApplicationReady preload is best-effort; a missing Registry response must not fail startup.
|
||||
}
|
||||
}
|
||||
74
src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
Normal file
74
src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package io.shinhanlife.dap.biz.mcp;
|
||||
|
||||
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;
|
||||
|
||||
public final class TestFixtures {
|
||||
|
||||
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private TestFixtures() {
|
||||
}
|
||||
|
||||
public static McpProperties properties(boolean redisEnabled, boolean forwardAuthorization) {
|
||||
return properties(redisEnabled, forwardAuthorization, List.of());
|
||||
}
|
||||
|
||||
public static McpProperties properties(
|
||||
boolean redisEnabled, boolean forwardAuthorization, List<McpProperties.Bundle> bundles) {
|
||||
return new McpProperties(
|
||||
"mcp-test",
|
||||
"/mcp",
|
||||
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.Trace(true, 1_048_576),
|
||||
new McpProperties.Protocol(List.of("2025-06-18"), "2025-06-18"),
|
||||
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
||||
bundles);
|
||||
}
|
||||
|
||||
public static McpProperties.Bundle bundle(
|
||||
String id, String manifestUrl, String baseEndpoint, String namePrefix) {
|
||||
return new McpProperties.Bundle(id, manifestUrl, baseEndpoint, namePrefix, true, null);
|
||||
}
|
||||
|
||||
public static McpRequestContext context() {
|
||||
return new McpRequestContext(
|
||||
"req-1",
|
||||
"guid-1",
|
||||
"session-1",
|
||||
"ENC(employee-1)",
|
||||
"ENC(virtual-1)",
|
||||
"Bearer test-token",
|
||||
Instant.parse("2030-01-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
public static ToolMetadata tool(String endpoint) {
|
||||
try {
|
||||
return new ToolMetadata(
|
||||
"customer.search",
|
||||
"1.0.0",
|
||||
"Search customer information",
|
||||
endpoint,
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{"type":"object","properties":{"customerNo":{"type":"string"}},
|
||||
"required":["customerNo"]}
|
||||
"""),
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package io.shinhanlife.dap.biz.mcp.config;
|
||||
|
||||
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 java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* bundle 설정이 라우팅을 모호하게 만들지 않는지 기동 시점에 걸러내는 검증 규칙을 확인하는 테스트입니다. 이 규칙들이 없으면 잘못된 설정이 기동에는 성공하고 운영 중 엉뚱한 Tool 라우팅으로 나타납니다.
|
||||
*/
|
||||
class McpBundleConfigurationTest {
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateBundleIds() {
|
||||
McpProperties properties =
|
||||
properties(
|
||||
false,
|
||||
false,
|
||||
List.of(
|
||||
bundle("same", "http://a/manifest", "http://a/mcp", "a."),
|
||||
bundle("same", "http://b/manifest", "http://b/mcp", "b.")));
|
||||
|
||||
assertThat(properties.isBundleRoutingUnambiguous()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsANamePrefixThatIsAPrefixOfAnother() {
|
||||
// "a."와 "a.b."가 동시에 있으면 "a.b.search"가 어느 bundle 소속인지 확정되지 않는다.
|
||||
McpProperties properties =
|
||||
properties(
|
||||
false,
|
||||
false,
|
||||
List.of(
|
||||
bundle("outer", "http://a/manifest", "http://a/mcp", "a."),
|
||||
bundle("inner", "http://b/manifest", "http://b/mcp", "a.b.")));
|
||||
|
||||
assertThat(properties.isBundleRoutingUnambiguous()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDisjointPrefixes() {
|
||||
McpProperties properties =
|
||||
properties(
|
||||
false,
|
||||
false,
|
||||
List.of(
|
||||
bundle("alpha", "http://a/manifest", "http://a/mcp", "alpha."),
|
||||
bundle("beta", "http://b/manifest", "http://b/mcp", "beta.")));
|
||||
|
||||
assertThat(properties.isBundleRoutingUnambiguous()).isTrue();
|
||||
assertThat(properties.isDiscoveryTargetDeclared()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWithoutAnyBundle() {
|
||||
McpProperties properties =
|
||||
new McpProperties(
|
||||
"mcp-test",
|
||||
"/mcp",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
|
||||
List.of());
|
||||
|
||||
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDiscoveryWhenEveryDeclaredBundleIsDisabled() {
|
||||
McpProperties.Bundle disabled =
|
||||
new McpProperties.Bundle(
|
||||
"disabled",
|
||||
"http://tool/manifest",
|
||||
"http://tool/mcp",
|
||||
"disabled.",
|
||||
false,
|
||||
null);
|
||||
McpProperties properties = properties(false, false, List.of(disabled));
|
||||
|
||||
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsAMissingBundleListAsEmpty() {
|
||||
McpProperties properties =
|
||||
new McpProperties("mcp-test", "/mcp", null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(properties.bundles()).isEmpty();
|
||||
assertThat(properties.enabledBundles()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package io.shinhanlife.dap.biz.mcp.contract;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.modelcontextprotocol.json.schema.jackson3.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;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.biz.mcp.method.InitializeHandler;
|
||||
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을 테스트가 직접 읽으므로 문서와 코드가 조용히 어긋나면 실패합니다.
|
||||
* 응답 모양을 바꾸려면 예제 파일과 구현을 함께 바꿔야 합니다.
|
||||
*/
|
||||
class AgentBuilderContractExampleTest {
|
||||
|
||||
private static final Path EXAMPLES =
|
||||
Path.of("docs", "contracts", "agent-builder-mcp", "examples", "agentbuilder-v0.3");
|
||||
|
||||
/**
|
||||
* 계약 예제 파일을 읽어 JSON으로 반환하고, 파일이 없으면 원인을 드러내며 실패합니다.
|
||||
*/
|
||||
private static JsonNode example(String fileName) throws IOException {
|
||||
Path file = EXAMPLES.resolve(fileName);
|
||||
assertThat(Files.exists(file))
|
||||
.withFailMessage("계약 예제를 찾을 수 없습니다: %s (작업 디렉터리=%s)", file, Path.of("").toAbsolutePath())
|
||||
.isTrue();
|
||||
return OBJECT_MAPPER.readTree(Files.readString(file, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializeResponseMatchesThePublishedExample() throws Exception {
|
||||
JsonNode golden = example("initialize-response.json");
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("initialize", OBJECT_MAPPER.createObjectNode(), golden.get("id"));
|
||||
|
||||
JsonRpcResponse response =
|
||||
new InitializeHandler(properties(false, false)).handle(request, context());
|
||||
|
||||
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
|
||||
assertThat(actual).isEqualTo(golden);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsListResponseMatchesThePublishedExample() throws Exception {
|
||||
JsonNode golden = example("tools-list-response.json");
|
||||
// publicDefinition을 채운다. 두 원천(LocalFileToolRegistryClient, ToolBundleDiscovery)이 모두
|
||||
// 이 값을 채우므로, null로 두면 실제로는 쓰이지 않는 fallback 분기만 검증하게 된다.
|
||||
List<ToolMetadata> registryTools = new ArrayList<>();
|
||||
for (JsonNode tool : golden.path("result").path("tools")) {
|
||||
registryTools.add(
|
||||
new ToolMetadata(
|
||||
tool.path("name").asString(),
|
||||
"1.0.0",
|
||||
tool.path("description").asString(),
|
||||
"https://tool.example/mcp",
|
||||
tool.get("inputSchema"),
|
||||
3_000,
|
||||
true,
|
||||
tool));
|
||||
}
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools()).thenReturn(registryTools);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id"));
|
||||
|
||||
JsonRpcResponse response =
|
||||
new ToolsListHandler(registryService, OBJECT_MAPPER).handle(request, context());
|
||||
|
||||
// 내부 endpoint/version은 공개 응답에 나타나지 않아야 한다.
|
||||
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
|
||||
assertThat(actual).isEqualTo(golden);
|
||||
}
|
||||
|
||||
/**
|
||||
* publicDefinition에 실행용 {@code _meta}가 섞여 있어도 공개 응답에는 나가지 않아야 합니다. 두 원천 모두 {@code _meta}를 제거해서 넘기지만, 그 제거가 사라져도 이 경로가 막아야 하므로 handler 쪽에서 확인합니다.
|
||||
*/
|
||||
@Test
|
||||
void toolsListNeverLeaksExecutionMetadata() throws Exception {
|
||||
JsonNode golden = example("tools-list-response.json");
|
||||
JsonNode first = golden.path("result").path("tools").get(0);
|
||||
ObjectNode leaky = ((ObjectNode) first).deepCopy();
|
||||
leaky.set(
|
||||
"_meta",
|
||||
OBJECT_MAPPER.readTree(
|
||||
"{\"endpoint\":\"https://internal.example/mcp\",\"timeoutMillis\":3000}"));
|
||||
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools())
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new ToolMetadata(
|
||||
first.path("name").asString(),
|
||||
"1.0.0",
|
||||
first.path("description").asString(),
|
||||
"https://tool.example/mcp",
|
||||
first.get("inputSchema"),
|
||||
3_000,
|
||||
true,
|
||||
leaky)));
|
||||
|
||||
JsonRpcResponse response =
|
||||
new ToolsListHandler(registryService, OBJECT_MAPPER)
|
||||
.handle(
|
||||
new JsonRpcRequest(
|
||||
"tools/list", OBJECT_MAPPER.createObjectNode(), golden.get("id")),
|
||||
context());
|
||||
|
||||
String serialized = OBJECT_MAPPER.writeValueAsString(response);
|
||||
assertThat(serialized).doesNotContain("internal.example").doesNotContain("timeoutMillis");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCallSuccessResponseMatchesThePublishedExample() throws Exception {
|
||||
JsonNode requestExample = example("tools-call-request.json");
|
||||
JsonNode golden = example("tools-call-success-response.json");
|
||||
JsonNode goldenContent = golden.path("result").path("content").get(0);
|
||||
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
when(service.execute(any(), any()))
|
||||
.thenReturn(
|
||||
new ToolExecutionService.Result(
|
||||
OBJECT_MAPPER.getNodeFactory().stringNode(goldenContent.path("text").asString()),
|
||||
goldenContent.path("_meta").path("searchTime").asDouble()));
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
requestExample.path("method").asString(),
|
||||
requestExample.get("params"),
|
||||
requestExample.get("id"));
|
||||
|
||||
JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context());
|
||||
|
||||
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
|
||||
assertThat(actual).isEqualTo(golden);
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
when(service.execute(any(), any()))
|
||||
.thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_TIMEOUT, goldenText));
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
|
||||
golden.get("id"));
|
||||
|
||||
JsonRpcResponse response = new ToolsCallHandler(service).handle(request, context());
|
||||
|
||||
// Tool 실행 실패는 최상위 JSON-RPC error가 아니라 isError=true result로 나가야 한다.
|
||||
assertThat(response.error()).isNull();
|
||||
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
|
||||
assertThat(actual).isEqualTo(golden);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidParamsErrorCodeAndMessageMatchThePublishedExample() throws Exception {
|
||||
JsonNode golden = example("tools-call-invalid-params-response.json");
|
||||
ToolArgumentValidator validator =
|
||||
new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator());
|
||||
ToolCall call = new ToolCall("processing", OBJECT_MAPPER.readTree("{}"));
|
||||
ToolMetadata metadata =
|
||||
new ToolMetadata(
|
||||
"processing",
|
||||
"1.0.0",
|
||||
"Processing",
|
||||
"https://tool.example/mcp",
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}
|
||||
"""),
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
|
||||
JsonRpcException thrown = null;
|
||||
try {
|
||||
validator.validate(call, metadata);
|
||||
} catch (JsonRpcException exception) {
|
||||
thrown = exception;
|
||||
}
|
||||
assertThat(thrown).isNotNull();
|
||||
|
||||
JsonRpcResponse response =
|
||||
JsonRpcResponse.failure(golden.get("id"), thrown.errorCode(), thrown.errorData());
|
||||
JsonNode actual = OBJECT_MAPPER.valueToTree(response);
|
||||
|
||||
// 예제는 진단용 `error.data`(traceId/details)를 생략한 축약형이므로 code/message만 대조한다.
|
||||
assertThat(actual.path("jsonrpc")).isEqualTo(golden.path("jsonrpc"));
|
||||
assertThat(actual.path("id")).isEqualTo(golden.path("id"));
|
||||
assertThat(actual.path("error").path("code")).isEqualTo(golden.path("error").path("code"));
|
||||
assertThat(actual.path("error").path("message"))
|
||||
.isEqualTo(golden.path("error").path("message"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package io.shinhanlife.dap.biz.mcp.contract;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
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 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;
|
||||
import java.time.Duration;
|
||||
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;
|
||||
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을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다. 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다. 조회 대상은 예제 매니페스트를 그대로 돌려주는
|
||||
* MockWebServer이며 실제 Tool Service를 호출하지 않습니다.
|
||||
*/
|
||||
class ToolBundleContractExampleTest {
|
||||
|
||||
private static final Path EXAMPLES =
|
||||
Path.of("docs/contracts/tool-service-mcp/examples/bundle-v0.2");
|
||||
|
||||
private MockWebServer server;
|
||||
|
||||
/**
|
||||
* 예제 매니페스트를 응답할 조회 대상 서버를 띄웁니다.
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
server = new MockWebServer();
|
||||
server.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회 대상 서버를 정리합니다.
|
||||
*/
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoversTheContractManifestExampleExactlyAsDocumented() throws Exception {
|
||||
String manifest = Files.readString(EXAMPLES.resolve("manifest-response.json"));
|
||||
server.enqueue(
|
||||
new MockResponse().setHeader("Content-Type", "application/json").setBody(manifest));
|
||||
McpProperties properties =
|
||||
properties(
|
||||
false,
|
||||
false,
|
||||
List.of(
|
||||
bundle(
|
||||
"insurance-processing",
|
||||
server.url("/tool-manifest").toString(),
|
||||
"http://tool-processing.ax-hub.svc.cluster.local:8080/mcp",
|
||||
"processing.")));
|
||||
|
||||
List<ToolMetadata> tools = discovery(properties).discoverAll().getFirst().tools();
|
||||
|
||||
assertThat(tools)
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly(
|
||||
"processing.contract.inquiry", "processing.payment.history", "processing.notice.send");
|
||||
// 실행 주소는 설정에서만 온다. 매니페스트에는 endpoint가 없고 있어도 무시한다.
|
||||
assertThat(tools)
|
||||
.allSatisfy(
|
||||
tool ->
|
||||
assertThat(tool.endpoint())
|
||||
.isEqualTo("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp"));
|
||||
// _meta는 tools/list 공개본에 노출하지 않는다.
|
||||
assertThat(tools)
|
||||
.allSatisfy(tool -> assertThat(tool.publicDefinition().has("_meta")).isFalse());
|
||||
assertThat(tools.getFirst().version()).isEqualTo("1.2.0");
|
||||
// enabled=false로 선언된 Tool은 조회는 되지만 ToolRegistryService가 목록에서 제외한다.
|
||||
assertThat(tools.stream().filter(ToolMetadata::enabled)).hasSize(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 매니페스트 예제가 {@code outputSchema}를 선언하지 않는지 확인합니다. MCP 2025-06-18에서 {@code outputSchema}를 선언한 서버는 그에 맞는 {@code structuredContent}를 제공해야 하는데, 현재
|
||||
* {@code tools/call}은 {@code content[0].text}만 반환합니다. 예제가 이 규칙을 어기면 Tool 개발자가 예제를 그대로 베껴 표준 위반 매니페스트를 만들게 되므로 계약(§5)을 테스트로 고정합니다.
|
||||
*/
|
||||
@Test
|
||||
void theManifestExampleDeclaresNoOutputSchema() throws Exception {
|
||||
JsonNode manifest =
|
||||
OBJECT_MAPPER.readTree(Files.readString(EXAMPLES.resolve("manifest-response.json")));
|
||||
|
||||
assertThat(manifest.path("tools"))
|
||||
.allSatisfy(
|
||||
tool ->
|
||||
assertThat(tool.has("outputSchema"))
|
||||
.withFailMessage(
|
||||
"운영 매니페스트 예제는 outputSchema를 선언하지 않는다 (v0.2 §5): %s",
|
||||
tool.path("name").asString())
|
||||
.isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationalStatusExampleMatchesTheImplementedResponseShape() throws Exception {
|
||||
JsonNode example =
|
||||
OBJECT_MAPPER.readTree(Files.readString(EXAMPLES.resolve("bundle-status-response.json")));
|
||||
Set<String> documented =
|
||||
OBJECT_MAPPER
|
||||
.convertValue(
|
||||
example.path("bundles").get(0), new TypeReference<Map<String, Object>>() {
|
||||
})
|
||||
.keySet();
|
||||
List<String> implemented =
|
||||
Arrays.stream(BundleStatus.class.getRecordComponents())
|
||||
.map(RecordComponent::getName)
|
||||
.toList();
|
||||
|
||||
// 문서 예제와 구현 응답의 field가 어긋나면 운영자가 없는 field를 보고 대시보드를 만들게 된다.
|
||||
assertThat(documented).containsExactlyInAnyOrderElementsOf(implemented);
|
||||
assertThat(example.path("bundles"))
|
||||
.anySatisfy(node -> assertThat(node.path("status").asString()).isEqualTo("disabled"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 예제 조회에 사용할 discovery 구성요소를 만듭니다.
|
||||
*/
|
||||
private ToolBundleDiscovery discovery(McpProperties properties) {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofMillis(properties.discovery().connectTimeoutMillis()));
|
||||
factory.setReadTimeout(Duration.ofMillis(properties.discovery().readTimeoutMillis()));
|
||||
return new ToolBundleDiscovery(
|
||||
RestClient.builder().requestFactory(factory).build(), OBJECT_MAPPER, properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package io.shinhanlife.dap.biz.mcp.deploy;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라
|
||||
* CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다.
|
||||
*
|
||||
* <p>이 테스트가 고정하는 핵심 규칙은 MCP 배포와 Tool Service의 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라
|
||||
* 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다.
|
||||
*/
|
||||
class HelmDeploymentContractTest {
|
||||
|
||||
private static final Path CHART = Path.of("deploy", "helm", "mcp-server");
|
||||
private static final Path VALUES = CHART.resolve("values.yaml");
|
||||
|
||||
/**
|
||||
* 환경별 values가 파싱되고 {@code global.env}가 파일 이름과 일치하는지 확인합니다. 이 값이 어긋나면 identity 접미사가 환경과 달라져 서로 다른 환경이 같은 Redis key를 쓰게 됩니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void environmentValuesDeclareTheMatchingEnvironmentAndPublicHost(String env) throws IOException {
|
||||
Map<String, Object> values = loadYaml(environmentValues(env));
|
||||
Map<String, Object> global = section(values, "global");
|
||||
|
||||
assertThat(global.get("env"))
|
||||
.withFailMessage("values-%s.yaml의 global.env가 파일 이름과 다릅니다.", env)
|
||||
.isEqualTo(env);
|
||||
assertThat(String.valueOf(global.get("mcpHost")))
|
||||
.withFailMessage("values-%s.yaml에 공개 MCP host가 없습니다.", env)
|
||||
.isNotBlank()
|
||||
.doesNotContain("null", "http://", "https://", "/");
|
||||
assertThat(String.valueOf(section(values, "route").get("sourceAllowlist")))
|
||||
.withFailMessage("values-%s.yaml에 Agent Builder source CIDR allowlist가 없습니다.", env)
|
||||
.isNotBlank()
|
||||
.doesNotContain("null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경별 values가 배포 토폴로지를 소유하지 않는지 확인합니다. 환경 축과 배포 축을 한 파일에 섞으면 배포가 늘어날 때마다 환경 설정이 복제되고, 같은 사실이 여러 파일에 흩어져 결국 서로 어긋납니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void environmentValuesDoNotOwnTheTopology(String env) throws IOException {
|
||||
Map<String, Object> values = loadYaml(environmentValues(env));
|
||||
|
||||
assertThat(values)
|
||||
.withFailMessage(
|
||||
"values-%s.yaml이 배포 토폴로지를 갖고 있습니다. deployments는 values.yaml 한 곳에만 둡니다.", env)
|
||||
.doesNotContainKeys("deployments", "deploymentKey");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 배포가 자기가 보는 Tool Service와 가용성 등급을 선언하는지 확인합니다. 주소가 아니라 서비스 이름만 선언해야 template이 namespace를 붙여 조립할 수 있고, values에 URL을 직접 적기 시작하면 오타가 그대로 라우팅 사고가 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void everyDeploymentDeclaresItsToolServiceTierAndPublicPath() throws IOException {
|
||||
Map<String, Object> values = loadYaml(VALUES);
|
||||
Map<String, Object> deployments = section(values, "deployments");
|
||||
Set<String> knownTiers = section(values, "tiers").keySet();
|
||||
|
||||
assertThat(deployments)
|
||||
.withFailMessage("values.yaml에 deployments가 없습니다. 이 목록이 배포 토폴로지의 정본입니다.")
|
||||
.isNotEmpty();
|
||||
|
||||
deployments.forEach((key, raw) -> {
|
||||
Map<String, Object> deployment = asMap(raw);
|
||||
assertThat(deployment)
|
||||
.withFailMessage(
|
||||
"deployments.%s에 name/service/namePrefix/tier/publicPath가 모두 있어야 합니다: %s",
|
||||
key, deployment)
|
||||
.containsKeys("name", "service", "namePrefix", "tier", "publicPath");
|
||||
assertThat(deployment)
|
||||
.withFailMessage("deployments.%s가 주소를 직접 적고 있습니다. template이 조립합니다.", key)
|
||||
.doesNotContainKeys("manifestUrl", "baseEndpoint", "bundles");
|
||||
assertThat(String.valueOf(deployment.get("namePrefix")))
|
||||
.withFailMessage("deployments.%s의 namePrefix가 비어 있습니다.", key)
|
||||
.isNotBlank();
|
||||
assertThat(String.valueOf(deployment.get("publicPath")))
|
||||
.withFailMessage("deployments.%s의 publicPath가 /mcp/<영문 소문자·숫자·하이픈> 형식이 아닙니다.", key)
|
||||
.matches("/mcp/[a-z0-9-]+");
|
||||
assertThat(knownTiers)
|
||||
.withFailMessage(
|
||||
"deployments.%s의 tier '%s'가 values.yaml의 tiers에 없습니다.", key, deployment.get("tier"))
|
||||
.contains(String.valueOf(deployment.get("tier")));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 path가 배포마다 유일한지 확인합니다. 같은 host와 path를 두 Route가 공유하면 어느 MCP Service로 전달될지 배포 순서에 따라 달라집니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentPublicPathsAreUnique() throws IOException {
|
||||
List<String> paths =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("publicPath")))
|
||||
.toList();
|
||||
|
||||
assertThat(paths)
|
||||
.withFailMessage("공개 MCP path가 중복됩니다. 한 path는 한 MCP Deployment만 가리켜야 합니다: %s", paths)
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 이름이 서로 겹치지 않는지 확인합니다. 이름은 Deployment·Service·ConfigMap·NetworkPolicy의 리소스 이름이 되므로, 같은 namespace에서 겹치면 나중에 설치한 배포가 앞의 것을 덮어씁니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentResourceNamesAreUnique() throws IOException {
|
||||
List<String> names =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("name")))
|
||||
.toList();
|
||||
|
||||
assertThat(names)
|
||||
.withFailMessage("배포 이름이 중복됩니다. 같은 namespace에서 리소스가 서로를 덮어씁니다: %s", names)
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 어떤 {@code namePrefix}도 다른 prefix의 <b>진부분</b> 접두사가 아닌지 확인합니다. {@code a.}와 {@code a.b.}가 함께 있으면 {@code a.b.search}가 어느 Tool Service 것인지 이름만으로는 확정되지 않습니다.
|
||||
* 서로 다른 MCP에 흩어져 있으면 MCP는 이를 감지할 수 없으므로 여기서 막습니다.
|
||||
*
|
||||
* <p>완전히 같은 prefix는 허용합니다. 같은 업무를 등급으로 나눈 두 배포가 같은 업무 prefix를
|
||||
* 공유하는 것은 의도된 구성입니다(ADR-0007). 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임입니다.
|
||||
*/
|
||||
@Test
|
||||
void noNamePrefixIsAStrictPrefixOfAnother() throws IOException {
|
||||
List<String> prefixes =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("namePrefix")))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
List<String> conflicts = new ArrayList<>();
|
||||
for (String outer : prefixes) {
|
||||
for (String inner : prefixes) {
|
||||
if (!outer.equals(inner) && inner.startsWith(outer)) {
|
||||
conflicts.add(outer + " ⊂ " + inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(conflicts)
|
||||
.withFailMessage("namePrefix가 다른 prefix의 접두사입니다. Tool 이름의 소속이 확정되지 않습니다: %s", conflicts)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigMap이 Tool Service를 정확히 하나만 묶는지 확인합니다. 1:1은 ADR-0007의 결정이며 production 코드가 아니라 여기서 잠급니다. bundle 목록을 {@code range}로 돌리기 시작하면 그 순간 M:N으로 되돌아가고, 등급이 다른
|
||||
* Tool Service가 한 MCP에 묶여 카탈로그 갱신이 서로를 막게 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void configMapBindsExactlyOneToolService() throws IOException {
|
||||
String configMap = Files.readString(CHART.resolve("templates/configmap.yaml"));
|
||||
|
||||
List<String> bundleEntries =
|
||||
configMap.lines().map(String::trim).filter(line -> line.startsWith("- id:")).toList();
|
||||
|
||||
assertThat(configMap).contains("bundles:");
|
||||
assertThat(bundleEntries)
|
||||
.withFailMessage("ConfigMap이 bundle을 정확히 하나만 만들어야 합니다(ADR-0007): %s", bundleEntries)
|
||||
.hasSize(1);
|
||||
assertThat(configMap)
|
||||
.withFailMessage("ConfigMap이 bundle 목록을 반복 렌더링하고 있습니다. 1:1이 깨졌습니다(ADR-0007).")
|
||||
.doesNotContain("range");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 환경이 사용 중인 등급을 빠짐없이 선언하는지 확인합니다. 환경 values가 등급 하나를 빠뜨리면 values.yaml의 기본값이 조용히 적용되어, dev인데 prod 기준 replica로 뜨거나 그 반대가 됩니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void everyEnvironmentDeclaresEveryTierInUse(String env) throws IOException {
|
||||
Set<String> tiersInUse =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("tier")))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<String> declared = section(loadYaml(environmentValues(env)), "tiers").keySet();
|
||||
|
||||
assertThat(declared)
|
||||
.withFailMessage("values-%s.yaml이 선언하지 않은 등급이 있습니다. 기본값이 조용히 적용됩니다.", env)
|
||||
.containsAll(tiersInUse);
|
||||
}
|
||||
|
||||
/**
|
||||
* test와 prod의 중요 등급이 단일 장애점을 갖지 않도록 설정됐는지 확인합니다. replica가 1이면 rolling update 중 반드시 공백이 생기고, PodDisruptionBudget이 없으면 노드 drain이 마지막 Pod을 내릴 수 있습니다. 노드 분산이
|
||||
* 꺼져 있으면 여러 replica가 같은 노드 장애를 공유하므로 세 설정은 함께 유지해야 합니다(ADR-0007). dev는 Pod 1개로 운영하므로 대상이 아닙니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"test", "prod"})
|
||||
void criticalTierDeclaresAvailabilitySettings(String env) throws IOException {
|
||||
Map<String, Object> critical = asMap(section(loadYaml(environmentValues(env)), "tiers").get("critical"));
|
||||
|
||||
assertThat((Integer) critical.get("replicas"))
|
||||
.withFailMessage("%s의 critical 등급 replica가 2 미만입니다. 배포 중 공백이 생깁니다: %s", env, critical)
|
||||
.isGreaterThanOrEqualTo(2);
|
||||
assertThat(critical.get("podDisruptionBudget"))
|
||||
.withFailMessage("%s의 critical 등급에 PodDisruptionBudget이 없습니다.", env)
|
||||
.isEqualTo(true);
|
||||
assertThat(critical.get("spreadAcrossNodes"))
|
||||
.withFailMessage("%s의 critical 등급이 replica를 노드에 분산하지 않습니다.", env)
|
||||
.isEqualTo(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* PodDisruptionBudget template이 존재하고 등급 설정으로 켜지는지 확인합니다. 값만 {@code true}로 두고 template이 없으면 아무 일도 일어나지 않은 채 검사만 통과합니다.
|
||||
*/
|
||||
@Test
|
||||
void podDisruptionBudgetTemplateUsesTheTierSetting() throws IOException {
|
||||
String pdb = Files.readString(CHART.resolve("templates/poddisruptionbudget.yaml"));
|
||||
|
||||
assertThat(pdb).contains("kind: PodDisruptionBudget").contains("$tier.podDisruptionBudget");
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 대상 배포에 기본값이 없는지, identity를 values가 직접 정하지 않는지 확인합니다. {@code deploymentKey}에 기본값이 있으면 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치됩니다. identity를 손으로 적으면 dev·test·prod가 같은
|
||||
* 값을 갖는 실수가 나고, 그 순간 서로의 Tool snapshot을 덮어씁니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentKeyAndIdentityAreNotDefaultedInValues() throws IOException {
|
||||
Map<String, Object> values = loadYaml(VALUES);
|
||||
|
||||
Object deploymentKey = values.get("deploymentKey");
|
||||
assertThat(deploymentKey == null || String.valueOf(deploymentKey).isEmpty())
|
||||
.withFailMessage("deploymentKey에 기본값 '%s'가 있습니다. 지정을 빠뜨린 설치가 조용히 성공합니다.", deploymentKey)
|
||||
.isTrue();
|
||||
assertThat(section(values, "mcp"))
|
||||
.withFailMessage("values.yaml이 identity를 직접 정하고 있습니다. helper가 조립해야 합니다.")
|
||||
.doesNotContainKey("identity");
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증을 하지 않는 전제인 NetworkPolicy가 Chart에서 빠지지 않았는지 확인합니다. ADR-0006의 성립 조건이므로 비활성화 조건 없이 항상 렌더링되어야 합니다.
|
||||
*/
|
||||
@Test
|
||||
void networkPolicyRestrictsBothPortsAndHasNoDisableSwitch() throws IOException {
|
||||
String policy = Files.readString(CHART.resolve("templates/networkpolicy.yaml"));
|
||||
|
||||
assertThat(policy)
|
||||
.contains("kind: NetworkPolicy")
|
||||
.contains("kubernetes.io/metadata.name: {{ .Values.global.agentBuilderNamespace }}")
|
||||
.contains("policy-group.network.openshift.io/ingress: \"\"")
|
||||
.contains("kubernetes.io/metadata.name: {{ .Values.global.monitoringNamespace }}")
|
||||
.contains("port: {{ .Values.ports.http }}")
|
||||
.contains("port: {{ .Values.ports.management }}");
|
||||
// {{ if .Values...enabled }}로 감싸면 values 한 줄로 인가가 사라진다.
|
||||
assertThat(policy).doesNotContain("{{- if").doesNotContain("{{ if");
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenShift Route가 환경별 공통 host와 배포별 고유 path를 사용해 선택된 MCP Service로 전달하는지 확인합니다. path rewrite는 금지하며 TLS와 route timeout은 공개 HTTP 경계에 둡니다.
|
||||
*/
|
||||
@Test
|
||||
void routeMapsThePublicPathToTheSelectedMcpService() throws IOException {
|
||||
String route = Files.readString(CHART.resolve("templates/route.yaml"));
|
||||
|
||||
assertThat(route)
|
||||
.contains("apiVersion: route.openshift.io/v1")
|
||||
.contains("kind: Route")
|
||||
.doesNotContain("haproxy.router.openshift.io/rewrite-target")
|
||||
.contains("haproxy.router.openshift.io/timeout: {{ .Values.route.timeout }}")
|
||||
.contains("haproxy.router.openshift.io/ip_allowlist: {{ .Values.route.sourceAllowlist | quote }}")
|
||||
.contains("host: {{ .Values.global.mcpHost | quote }}")
|
||||
.contains("path: {{ $deployment.publicPath | quote }}")
|
||||
.contains("kind: Service")
|
||||
.contains("name: {{ include \"mcp-server.name\" . }}")
|
||||
.contains("targetPort: http")
|
||||
.contains("termination: edge")
|
||||
.contains("insecureEdgeTerminationPolicy: Redirect");
|
||||
|
||||
assertThat(Files.readString(CHART.resolve("templates/configmap.yaml")))
|
||||
.contains("endpoint-path: {{ $deployment.publicPath | quote }}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deployment가 운영 profile과 ConfigMap 우선 적용을 유지하는지, replica를 등급에서 가져오는지 확인합니다. ConfigMap checksum annotation이 빠지면 bundle 설정을 고쳐도 기존 Pod이 옛 설정으로 계속 돕니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentUsesOperationalProfileAndRollsOnConfigChange() throws IOException {
|
||||
String deployment = Files.readString(CHART.resolve("templates/deployment.yaml"));
|
||||
|
||||
assertThat(deployment)
|
||||
.contains("name: SPRING_PROFILES_ACTIVE")
|
||||
.contains("value: ocp")
|
||||
.contains("SPRING_CONFIG_ADDITIONAL_LOCATION")
|
||||
.contains("checksum/config:")
|
||||
.contains("replicas: {{ $tier.replicas }}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경별 values 파일 경로를 만듭니다.
|
||||
*/
|
||||
private Path environmentValues(String env) {
|
||||
return CHART.resolve("values-" + env + ".yaml");
|
||||
}
|
||||
|
||||
/**
|
||||
* values 파일을 YAML로 읽습니다.
|
||||
*/
|
||||
@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));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최상위 절을 꺼내되 없으면 빈 map을 돌려줘 호출부가 null을 검사하지 않게 합니다.
|
||||
*/
|
||||
private Map<String, Object> section(Map<String, Object> values, String name) {
|
||||
return asMap(values.get(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML이 map으로 읽힌 값을 꺼내되 없으면 빈 map을 돌려줍니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> asMap(Object value) {
|
||||
return value == null ? new LinkedHashMap<>() : (Map<String, Object>) value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package io.shinhanlife.dap.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
|
||||
* 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class ArchitectureDocumentContractTest {
|
||||
|
||||
private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
|
||||
private static final Path MAIN_PACKAGE =
|
||||
Path.of("src", "main", "java", "io", "shinhanlife", "dap", "biz", "mcp");
|
||||
/**
|
||||
* 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
|
||||
*/
|
||||
private static final Pattern TABLE_ROW =
|
||||
Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
|
||||
|
||||
/**
|
||||
* 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
|
||||
*/
|
||||
@Test
|
||||
void everyDocumentedClassPathStillExists() throws IOException {
|
||||
List<DocumentedType> documented = documentedTypes();
|
||||
|
||||
// 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
|
||||
assertThat(documented)
|
||||
.withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
|
||||
.hasSizeGreaterThan(10);
|
||||
|
||||
List<DocumentedType> missing = documented.stream().filter(type -> !sourceExists(type)).toList();
|
||||
|
||||
assertThat(missing)
|
||||
.withFailMessage(
|
||||
"architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
|
||||
+ "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
|
||||
missing)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
|
||||
*/
|
||||
private List<DocumentedType> documentedTypes() throws IOException {
|
||||
try (Stream<String> lines = Files.lines(ARCHITECTURE)) {
|
||||
return lines.map(TABLE_ROW::matcher)
|
||||
.filter(Matcher::find)
|
||||
.map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
|
||||
*/
|
||||
private boolean sourceExists(DocumentedType type) {
|
||||
return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
|
||||
}
|
||||
|
||||
private record DocumentedType(String name, String packagePath) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package io.shinhanlife.dap.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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 java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
|
||||
* 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
|
||||
*
|
||||
* <p>여기서 보는 것은 <b>도구 없이도 판정할 수 있는 규칙</b>뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
|
||||
* 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class CodeStyleContractTest {
|
||||
|
||||
private static final List<Path> SOURCE_ROOTS =
|
||||
List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java"));
|
||||
/**
|
||||
* {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다.
|
||||
*/
|
||||
private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);");
|
||||
|
||||
/**
|
||||
* 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다.
|
||||
*/
|
||||
@Test
|
||||
void everySourceUsesUnixLineEndings() throws IOException {
|
||||
List<String> broken = violations(source -> source.raw().contains("\r\n"));
|
||||
|
||||
assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
|
||||
*/
|
||||
@Test
|
||||
void noSourceContainsTabCharacters() throws IOException {
|
||||
List<String> broken = violations(source -> source.raw().contains("\t"));
|
||||
|
||||
assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
|
||||
*/
|
||||
@Test
|
||||
void noLineEndsWithWhitespace() throws IOException {
|
||||
List<String> broken =
|
||||
violations(
|
||||
source ->
|
||||
source.lines().stream()
|
||||
.anyMatch(line -> !line.equals(line.stripTrailing())));
|
||||
|
||||
assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다.
|
||||
*/
|
||||
@Test
|
||||
void everySourceEndsWithExactlyOneNewline() throws IOException {
|
||||
List<String> broken =
|
||||
violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
|
||||
|
||||
assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
|
||||
*
|
||||
* <p>판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
|
||||
*/
|
||||
@Test
|
||||
void noSourceKeepsAnUnusedImport() throws IOException {
|
||||
List<String> unused = new ArrayList<>();
|
||||
for (JavaSource source : sources()) {
|
||||
String body =
|
||||
String.join(
|
||||
"\n",
|
||||
source.lines().stream().filter(line -> !line.startsWith("import ")).toList());
|
||||
for (String line : source.lines()) {
|
||||
Matcher matcher = IMPORT.matcher(line);
|
||||
if (matcher.find() && !containsWord(body, matcher.group(1))) {
|
||||
unused.add(source.path() + " -> " + matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다.
|
||||
*
|
||||
* <p>비교는 <b>세미콜론을 뗀 경로</b>로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
|
||||
* 판정합니다.
|
||||
*
|
||||
* <p>그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
|
||||
* 잠그는 편이 낫습니다.
|
||||
*/
|
||||
@Test
|
||||
void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
|
||||
List<String> broken = new ArrayList<>();
|
||||
for (JavaSource source : sources()) {
|
||||
List<String> statics = new ArrayList<>();
|
||||
List<String> regular = new ArrayList<>();
|
||||
for (String line : source.lines()) {
|
||||
if (line.startsWith("import static ")) {
|
||||
statics.add(line.substring("import static ".length()).replace(";", ""));
|
||||
} else if (line.startsWith("import ")) {
|
||||
regular.add(line.substring("import ".length()).replace(";", ""));
|
||||
}
|
||||
}
|
||||
if (!isSorted(statics) || !isSorted(regular)) {
|
||||
broken.add(source.path());
|
||||
}
|
||||
if (!source.staticImportsComeFirst()) {
|
||||
broken.add(source.path() + " (static import가 일반 import 뒤에 있음)");
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다.
|
||||
*/
|
||||
@Test
|
||||
void theSourceSetIsActuallyScanned() throws IOException {
|
||||
assertThat(sources())
|
||||
.withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.")
|
||||
.hasSizeGreaterThan(50);
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다.
|
||||
*/
|
||||
private List<String> violations(Predicate<JavaSource> broken) throws IOException {
|
||||
return sources().stream().filter(broken).map(JavaSource::path).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
|
||||
*/
|
||||
private boolean isSorted(List<String> values) {
|
||||
return values.equals(values.stream().sorted().toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다.
|
||||
*/
|
||||
private boolean containsWord(String text, String word) {
|
||||
return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* main과 test의 모든 Java 소스를 읽어 옵니다.
|
||||
*/
|
||||
private List<JavaSource> sources() throws IOException {
|
||||
List<JavaSource> sources = new ArrayList<>();
|
||||
for (Path root : SOURCE_ROOTS) {
|
||||
try (Stream<Path> paths = Files.walk(root)) {
|
||||
for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) {
|
||||
sources.add(
|
||||
new JavaSource(
|
||||
path.toString().replace('\\', '/'),
|
||||
new String(Files.readAllBytes(path), StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다.
|
||||
*/
|
||||
private record JavaSource(String path, String raw) {
|
||||
|
||||
/**
|
||||
* 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다.
|
||||
*/
|
||||
List<String> lines() {
|
||||
return List.of(raw.split("\n", -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
|
||||
*/
|
||||
boolean staticImportsComeFirst() {
|
||||
List<String> lines = lines();
|
||||
int lastStatic = -1;
|
||||
int firstRegular = Integer.MAX_VALUE;
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
String line = lines.get(index);
|
||||
if (line.startsWith("import static ")) {
|
||||
lastStatic = index;
|
||||
} else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) {
|
||||
firstRegular = index;
|
||||
}
|
||||
}
|
||||
return lastStatic < firstRegular;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package io.shinhanlife.dap.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
|
||||
* 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class PackageBoundaryContractTest {
|
||||
|
||||
private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
|
||||
/**
|
||||
* 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
|
||||
*/
|
||||
private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dap/biz/mcp/transport/";
|
||||
|
||||
/**
|
||||
* 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
|
||||
*/
|
||||
@Test
|
||||
void servletApiStaysInsideTheTransportPackage() throws IOException {
|
||||
List<Path> leaks = sourcesImporting("jakarta.servlet").stream()
|
||||
.filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(leaks)
|
||||
.withFailMessage(
|
||||
"jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
|
||||
+ "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
|
||||
leaks)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
|
||||
*/
|
||||
@Test
|
||||
void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
|
||||
List<Path> violations = sourcesImportingAny(List.of(
|
||||
"io.shinhanlife.dap.biz.mcp.execute.",
|
||||
"io.shinhanlife.dap.biz.mcp.registry."))
|
||||
.stream()
|
||||
.filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(violations)
|
||||
.withFailMessage(
|
||||
"transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
|
||||
violations)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImportingAny(List<String> importPrefixes) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresAnyImport(path, importPrefixes))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImporting(String importPrefix) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresImport(path, importPrefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
|
||||
*/
|
||||
private boolean declaresImport(Path path, String importPrefix) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
|
||||
*/
|
||||
private boolean declaresAnyImport(Path path, List<String> importPrefixes) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> importPrefixes.stream()
|
||||
.anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
|
||||
*/
|
||||
private String normalize(Path path) {
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.shinhanlife.dap.biz.mcp.execute;
|
||||
|
||||
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.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolArgumentValidatorTest {
|
||||
|
||||
private final ToolArgumentValidator validator =
|
||||
new ToolArgumentValidator(OBJECT_MAPPER, new DefaultJsonSchemaValidator());
|
||||
|
||||
@Test
|
||||
void reportsMissingRequiredQueryAsInvalidParams() throws Exception {
|
||||
ToolCall call = new ToolCall("document.search", OBJECT_MAPPER.readTree("{}"));
|
||||
ToolMetadata metadata =
|
||||
new ToolMetadata(
|
||||
"document.search",
|
||||
"1.0.0",
|
||||
"Search documents",
|
||||
"http://tool.example/search",
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}
|
||||
"""),
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(call, metadata))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
|
||||
assertThat(exception.errorData()).isEqualTo("'query' is required");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesJsonSchemaKeywordsBeforeCallingTheTool() throws Exception {
|
||||
ToolCall call =
|
||||
new ToolCall(
|
||||
"document.search", OBJECT_MAPPER.readTree("{\"query\":\"\",\"unexpected\":true}"));
|
||||
ToolMetadata metadata =
|
||||
new ToolMetadata(
|
||||
"document.search",
|
||||
"1.0.0",
|
||||
"Search documents",
|
||||
"http://tool.example/search",
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{
|
||||
"type":"object",
|
||||
"properties":{"query":{"type":"string","minLength":1}},
|
||||
"required":["query"],
|
||||
"additionalProperties":false
|
||||
}
|
||||
"""),
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(call, metadata))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
|
||||
assertThat(exception.errorData()).isEqualTo("arguments do not match inputSchema");
|
||||
assertThat(exception.errorData().toString()).doesNotContain("unexpected");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.shinhanlife.dap.biz.mcp.execute;
|
||||
|
||||
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.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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.ToolRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolExecutionServiceTest {
|
||||
|
||||
@Test
|
||||
void executesOnePreparedToolAndReturnsItsResult() 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/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(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"order\":1}")));
|
||||
ToolExecutionService service =
|
||||
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
|
||||
|
||||
var result = service.execute(call, context());
|
||||
|
||||
assertThat(result.data().path("order").asInt()).isEqualTo(1);
|
||||
verify(validator).validate(call, metadata);
|
||||
verify(client).execute(request, context());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesArgumentsBeforeCallingTheTool() 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("weather", OBJECT_MAPPER.readTree("{\"city\":\"Seoul\"}"));
|
||||
ToolMetadata metadata = tool("http://tool");
|
||||
ToolRequest request =
|
||||
new ToolRequest(
|
||||
"weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000);
|
||||
when(registry.findEnabledTool(call.toolName())).thenReturn(metadata);
|
||||
when(routing.route(call, metadata)).thenReturn(request);
|
||||
when(client.execute(request, context()))
|
||||
.thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}")));
|
||||
ToolExecutionService service =
|
||||
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
|
||||
|
||||
service.execute(call, context());
|
||||
|
||||
verify(validator).validate(call, metadata);
|
||||
verify(client).execute(request, context());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package io.shinhanlife.dap.biz.mcp.execute;
|
||||
|
||||
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 io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolRoutingServiceTest {
|
||||
|
||||
@Test
|
||||
void appendsToolNameForTheSinglePostRoutingContract() throws Exception {
|
||||
ToolMetadata metadata =
|
||||
new ToolMetadata(
|
||||
"weather",
|
||||
"1.0.0",
|
||||
"weather",
|
||||
"https://axhub-tool-other.onrender.com/mcp",
|
||||
null,
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
ToolCall call = new ToolCall("weather", OBJECT_MAPPER.readTree("{\"city\":\"Seoul\"}"));
|
||||
|
||||
var request = new ToolRoutingService(properties(false, false)).route(call, metadata);
|
||||
|
||||
assertThat(request.endpoint()).isEqualTo("https://axhub-tool-other.onrender.com/mcp/weather");
|
||||
assertThat(request.arguments()).isNotSameAs(call.arguments());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.shinhanlife.dap.biz.mcp.jsonrpc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
class JsonRpcRequestParserTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private JsonRpcRequestParser parser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
parser = new JsonRpcRequestParser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptsValidRequest() throws Exception {
|
||||
JsonRpcRequest request =
|
||||
parser.parse(
|
||||
objectMapper.readTree(
|
||||
"""
|
||||
{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"req-1"}
|
||||
"""));
|
||||
|
||||
assertThat(request.method()).isEqualTo("tools/list");
|
||||
assertThat(request.id().asString()).isEqualTo("req-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongJsonRpcVersionAndKeepsRequestId() throws Exception {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
parser.parse(
|
||||
objectMapper.readTree(
|
||||
"""
|
||||
{"jsonrpc":"1.0","method":"tools/list","id":"req-2"}
|
||||
""")))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_REQUEST);
|
||||
assertThat(exception.requestId().asString()).isEqualTo("req-2");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 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() {
|
||||
InitializeHandler handler = new InitializeHandler(properties(false, false));
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"initialize",
|
||||
JsonNodeFactory.instance.objectNode(),
|
||||
JsonNodeFactory.instance.numberNode(1));
|
||||
|
||||
var response = handler.handle(request, null);
|
||||
|
||||
assertThat(response.jsonrpc()).isEqualTo("2.0");
|
||||
assertThat(response.id().asInt()).isEqualTo(1);
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.InitializeResult.class);
|
||||
tools.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}},
|
||||
"serverInfo":{
|
||||
"name":"shl-axhub-mcp-server",
|
||||
"title":"SHL AX HUB MCP Server",
|
||||
"version":"1.0.0"
|
||||
}
|
||||
}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.node.JsonNodeFactory;
|
||||
|
||||
class InitializedNotificationHandlerTest {
|
||||
|
||||
@Test
|
||||
void acceptsNotificationWithoutPersistingSessionState() {
|
||||
InitializedNotificationHandler handler = new InitializedNotificationHandler();
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"notifications/initialized", JsonNodeFactory.instance.objectNode(), null);
|
||||
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
assertThat(response.id()).isNull();
|
||||
assertThat(response.result()).isEqualTo(java.util.Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package io.shinhanlife.dap.biz.mcp.method;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
|
||||
import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
class ToolsCallHandlerTest {
|
||||
|
||||
@Test
|
||||
void returnsPlainTextToolResultWithSearchTime() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3));
|
||||
when(service.execute(any(), any()))
|
||||
.thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree("\"Hong\""), 976.1));
|
||||
|
||||
var response = new ToolsCallHandler(service).handle(request, context());
|
||||
|
||||
ArgumentCaptor<ToolCall> call = ArgumentCaptor.forClass(ToolCall.class);
|
||||
verify(service).execute(call.capture(), any());
|
||||
assertThat(call.getValue().toolName()).isEqualTo("customer.search");
|
||||
assertThat(response.id()).isEqualTo(request.id());
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.CallToolResult.class);
|
||||
JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized)
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{
|
||||
"content":[{
|
||||
"type":"text",
|
||||
"text":"Hong",
|
||||
"_meta":{"searchTime":976.1}
|
||||
}],
|
||||
"isError":false
|
||||
}
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesJsonToolResponseAsOneEscapedTextValue() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"users\",\"arguments\":{}}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3));
|
||||
String toolResponse = "[{\"id\":1,\"name\":\"Leanne Graham\"}]";
|
||||
when(service.execute(any(), any()))
|
||||
.thenReturn(new ToolExecutionService.Result(OBJECT_MAPPER.readTree(toolResponse), 12.5));
|
||||
|
||||
var response = new ToolsCallHandler(service).handle(request, context());
|
||||
String serialized = OBJECT_MAPPER.writeValueAsString(response);
|
||||
|
||||
assertThat(
|
||||
OBJECT_MAPPER
|
||||
.readTree(serialized)
|
||||
.path("result")
|
||||
.path("content")
|
||||
.get(0)
|
||||
.path("text")
|
||||
.asString())
|
||||
.isEqualTo(toolResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsToolExecutionFailureAsMcpResult() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3));
|
||||
when(service.execute(any(), any()))
|
||||
.thenThrow(
|
||||
new JsonRpcException(
|
||||
JsonRpcErrorCode.TOOL_TIMEOUT, "customer.search@1.0.0: timed out"));
|
||||
|
||||
var response = new ToolsCallHandler(service).handle(request, context());
|
||||
|
||||
assertThat(response.error()).isNull();
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.CallToolResult.class);
|
||||
JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized)
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{
|
||||
"content":[{
|
||||
"type":"text",
|
||||
"text":"customer.search@1.0.0: timed out"
|
||||
}],
|
||||
"isError":true
|
||||
}
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesInvalidParamsAsAJsonRpcError() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":[]}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3));
|
||||
|
||||
ToolsCallHandler handler = new ToolsCallHandler(service);
|
||||
|
||||
assertThatThrownBy(() -> handler.handle(request, context()))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
|
||||
assertThat(exception.errorData()).isEqualTo("params.arguments must be an object");
|
||||
assertThat(exception.requestId()).isEqualTo(request.id());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingToolName() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"arguments\":{}}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(4));
|
||||
|
||||
assertThatThrownBy(() -> new ToolsCallHandler(service).handle(request, context()))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception -> {
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INVALID_PARAMS);
|
||||
assertThat(exception.errorData()).isEqualTo("params.name is required");
|
||||
assertThat(exception.requestId()).isEqualTo(request.id());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesServerConfigurationFailureAsAJsonRpcError() throws Exception {
|
||||
ToolExecutionService service = mock(ToolExecutionService.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/call",
|
||||
OBJECT_MAPPER.readTree("{\"name\":\"customer.search\",\"arguments\":{}}"),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3));
|
||||
when(service.execute(any(), any()))
|
||||
.thenThrow(
|
||||
new JsonRpcException(
|
||||
JsonRpcErrorCode.INTERNAL_ERROR, "Config-based direct Tool routing is disabled"));
|
||||
|
||||
ToolsCallHandler handler = new ToolsCallHandler(service);
|
||||
|
||||
assertThatThrownBy(() -> handler.handle(request, context()))
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode()).isEqualTo(JsonRpcErrorCode.INTERNAL_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package io.shinhanlife.dap.biz.mcp.method;
|
||||
|
||||
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.mockito.Mockito.mock;
|
||||
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 {
|
||||
|
||||
@Test
|
||||
void exposesOnlyMcpToolFieldsAndHidesInternalRegistryMetadata() throws Exception {
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.listTools())
|
||||
.thenReturn(List.of(tool("http://internal-tool.example/search")));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2"));
|
||||
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class);
|
||||
tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized)
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{
|
||||
"tools":[{
|
||||
"name":"customer.search",
|
||||
"description":"Search customer information",
|
||||
"inputSchema":{
|
||||
"type":"object",
|
||||
"properties":{"customerNo":{"type":"string"}},
|
||||
"required":["customerNo"]
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesLocalToolsListPublicFieldsAndHidesMetaExecutionFields() throws Exception {
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
var publicDefinition =
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{"name":"weather","title":"날씨 조회","description":"weather",
|
||||
"inputSchema":{"type":"object"},"outputSchema":{"type":"object"},
|
||||
"annotations":{"readOnlyHint":true}}
|
||||
""");
|
||||
var metadata =
|
||||
new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata(
|
||||
"weather",
|
||||
"1.0.0",
|
||||
"weather",
|
||||
"https://tool.example/mcp",
|
||||
publicDefinition.path("inputSchema"),
|
||||
3_000,
|
||||
true,
|
||||
publicDefinition);
|
||||
when(registryService.listTools()).thenReturn(List.of(metadata));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest("tools/list", OBJECT_MAPPER.readTree("{}"), OBJECT_MAPPER.readTree("2"));
|
||||
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class);
|
||||
assertThat(OBJECT_MAPPER.valueToTree(response.result()).path("tools").get(0))
|
||||
.isEqualTo(publicDefinition);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() {
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
var metadata =
|
||||
new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata(
|
||||
"legacy.lookup",
|
||||
"1.0.0",
|
||||
"Legacy lookup",
|
||||
"https://tool.example/mcp",
|
||||
null,
|
||||
3_000,
|
||||
true,
|
||||
null);
|
||||
when(registryService.listTools()).thenReturn(List.of(metadata));
|
||||
ToolsListHandler handler = new ToolsListHandler(registryService, OBJECT_MAPPER);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/list",
|
||||
OBJECT_MAPPER.createObjectNode(),
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(2));
|
||||
|
||||
var response = handler.handle(request, context());
|
||||
|
||||
tools.jackson.databind.JsonNode serialized = OBJECT_MAPPER.valueToTree(response.result());
|
||||
assertThat(serialized.path("tools").get(0).path("inputSchema"))
|
||||
.isEqualTo(
|
||||
OBJECT_MAPPER.readTree(
|
||||
"""
|
||||
{"type":"object","properties":{}}
|
||||
"""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
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;
|
||||
|
||||
/**
|
||||
* {@code toolCatalog} health indicator가 어느 probe에 연결되는지 고정하는 계약 테스트입니다.
|
||||
*
|
||||
* <p>이 indicator는 Tool Service라는 <b>외부 시스템</b>에 의존합니다. readiness에 연결하면 Tool을 읽지 못하는 Pod이 트래픽에서 빠지는, 의도한 동작이 됩니다. 그러나 같은 것을 liveness에 연결하면
|
||||
* Tool Service가 잠시 흔들릴 때 <b>모든 MCP Pod이 재시작 루프에 빠집니다.</b> readiness 실패는 트래픽만 끊지만 liveness 실패는 컨테이너를 죽이기 때문입니다.
|
||||
*
|
||||
* <p>"health 그룹을 통일하자"는 선의의 정리 한 번으로 장애가 전면화될 수 있어, 사람의 주의력 대신 테스트로 막습니다. 설정 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class HealthGroupContractTest {
|
||||
|
||||
private static final Path APPLICATION_YML =
|
||||
Path.of("src", "main", "resources", "application.yml");
|
||||
private static final String TOOL_CATALOG = "toolCatalog";
|
||||
|
||||
/**
|
||||
* readiness group이 {@code toolCatalog}를 포함하는지 확인합니다. 빠지면 usable snapshot이 없는 Pod도 트래픽을 받아, 배포 중 새 Pod이 정상 Pod을 대체하게 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void readinessIncludesTheToolCatalogIndicator() throws IOException {
|
||||
assertThat(groupMembers("readiness"))
|
||||
.withFailMessage("readiness group에 %s가 없습니다. 빈 카탈로그 Pod이 트래픽을 받게 됩니다.", TOOL_CATALOG)
|
||||
.contains(TOOL_CATALOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* liveness group이 {@code toolCatalog}를 포함하지 않는지 확인합니다. 포함되는 순간 Tool Service 장애가 MCP 전 Pod의 재시작 루프로 번집니다. group 선언 자체가 없으면 Spring 기본값이
|
||||
* {@code livenessState}만 쓰므로 안전합니다.
|
||||
*/
|
||||
@Test
|
||||
void livenessNeverIncludesTheToolCatalogIndicator() throws IOException {
|
||||
assertThat(groupMembers("liveness"))
|
||||
.withFailMessage(
|
||||
"liveness group에 %s가 있습니다. Tool Service 장애가 Pod 재시작 루프가 됩니다.", TOOL_CATALOG)
|
||||
.doesNotContain(TOOL_CATALOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code management.endpoint.health.group.<name>.include}에 선언된 항목을 읽어 옵니다. 선언이 없으면 빈 목록을 돌려줘 호출부가 null을 검사하지 않게 합니다.
|
||||
*/
|
||||
private List<String> groupMembers(String group) throws IOException {
|
||||
Map<String, Object> health =
|
||||
section(
|
||||
section(section(section(loadYaml(), "management"), "endpoint"), "health"),
|
||||
"group");
|
||||
Object include = section(health, group).get("include");
|
||||
if (include == null) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(String.valueOf(include).split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 기본 설정을 YAML로 읽습니다. profile별 파일이 아니라 모든 profile이 공유하는 이 파일이 probe 구성의 정본입니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> loadYaml() throws IOException {
|
||||
Load load = new Load(LoadSettings.builder().build());
|
||||
Object loaded = load.loadFromString(Files.readString(APPLICATION_YML));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 절을 꺼내되 없으면 빈 map을 돌려줍니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> section(Map<String, Object> values, String name) {
|
||||
Object value = values.get(name);
|
||||
return value == null ? new LinkedHashMap<>() : (Map<String, Object>) value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolBundleStatusEndpointTest {
|
||||
|
||||
@Test
|
||||
void exposesBundleStatusThroughTheManagementEndpointContract() {
|
||||
ToolBundleDiscovery discovery = mock(ToolBundleDiscovery.class);
|
||||
BundleStatus status =
|
||||
new BundleStatus(
|
||||
"channel-tools", true, "healthy", "rev-1", 2, 0, "2026-07-30T00:00:00Z", null);
|
||||
when(discovery.statuses()).thenReturn(List.of(status));
|
||||
|
||||
ToolBundleStatusEndpoint endpoint = new ToolBundleStatusEndpoint(discovery);
|
||||
|
||||
assertThat(endpoint.bundleStatuses()).containsEntry("bundles", List.of(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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;
|
||||
|
||||
class ToolCatalogHealthIndicatorTest {
|
||||
|
||||
@Test
|
||||
void staysDownUntilTheFirstDiscoveryAttemptFinishes() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.hasUsableSnapshot()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staysDownWhenDiscoveryFinishedWithoutAUsableSnapshot() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(scheduler.firstAttemptCompleted()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void becomesReadyWhenDiscoveryFinishedWithAUsableSnapshot() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(scheduler.firstAttemptCompleted()).thenReturn(true);
|
||||
when(registryService.hasUsableSnapshot()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
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 ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
class TraceLoggerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
McpRequestContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesTraceAndRequestIdsFromTheRequestContext() {
|
||||
var logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(TraceLogger.class);
|
||||
var appender = new ListAppender<ILoggingEvent>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
McpRequestContextHolder.set(context());
|
||||
|
||||
new TraceLogger(properties(false, false))
|
||||
.event("mcp_http_response_completed", "httpStatus", 200);
|
||||
|
||||
assertThat(appender.list)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
event ->
|
||||
assertThat(event.getFormattedMessage())
|
||||
.contains(
|
||||
"event=mcp_http_response_completed",
|
||||
"guid=guid-1",
|
||||
"requestId=req-1",
|
||||
"httpStatus=200"));
|
||||
logger.detachAppender(appender);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
|
||||
class LocalFileToolRegistryClientTest {
|
||||
|
||||
@Test
|
||||
void readsAgentBuilderToolsListResponseAndExtractsExecutionMetadataFromMeta() {
|
||||
LocalFileToolRegistryClient client =
|
||||
new LocalFileToolRegistryClient(
|
||||
new DefaultResourceLoader(), OBJECT_MAPPER, properties(false, false));
|
||||
|
||||
List<ToolMetadata> tools = client.fetchTools();
|
||||
|
||||
assertThat(tools)
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("core.weather");
|
||||
assertThat(tools)
|
||||
.allSatisfy(
|
||||
tool -> {
|
||||
assertThat(tool.enabled()).isTrue();
|
||||
assertThat(tool.endpoint()).isEqualTo("http://localhost:18080/mcp");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
class RedisToolRegistryCacheTest {
|
||||
|
||||
@Test
|
||||
void treatsRedisReadFailureAsCacheMiss() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(template.opsForValue()).thenReturn(values);
|
||||
when(values.get(any())).thenThrow(new IllegalStateException("redis unavailable"));
|
||||
RedisToolRegistryCache cache =
|
||||
new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false));
|
||||
|
||||
assertThat(cache.loadSnapshot()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresRedisWriteFailure() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(template.opsForValue()).thenReturn(values);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("redis unavailable"))
|
||||
.when(values)
|
||||
.set(any(), any(), any(Duration.class));
|
||||
RedisToolRegistryCache cache =
|
||||
new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false));
|
||||
|
||||
assertThatCode(() -> cache.saveSnapshot(List.of(tool("http://tool"))))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespacesKeyByMcpIdentityAndCacheSchemaVersion() {
|
||||
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||
RedisToolRegistryCache cache =
|
||||
new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false));
|
||||
|
||||
// 여러 MCP가 하나의 Redis를 공유해도 서로 덮어쓰지 않아야 하고,
|
||||
// 캐시 구조가 바뀐 버전이 옛 데이터를 읽어 오염되지 않아야 한다.
|
||||
assertThat(cache.key())
|
||||
.isEqualTo(
|
||||
"test:mcp:tools:mcp-test:" + RedisToolRegistryCache.CACHE_SCHEMA_VERSION + ":all");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
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.bundle;
|
||||
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.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 여러 Tool Service bundle을 동시에 조회·검증·병합하는 계약을 실제 HTTP 응답으로 검증하는 테스트입니다. 특히 한 bundle의 실패가 다른 bundle의 성공분을 버리지 않는지, 매니페스트가 실행 주소를 바꿀 수 없는지를 확인합니다.
|
||||
*/
|
||||
class ToolBundleDiscoveryTest {
|
||||
|
||||
private MockWebServer alpha;
|
||||
private MockWebServer beta;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
alpha = new MockWebServer();
|
||||
alpha.start();
|
||||
beta = new MockWebServer();
|
||||
beta.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
alpha.shutdown();
|
||||
beta.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesToolsFromEveryBundleInStableOrder() {
|
||||
alpha.enqueue(manifest("bundle-b", "b.second", "b.first"));
|
||||
beta.enqueue(manifest("bundle-a", "a.only"));
|
||||
McpProperties properties =
|
||||
withBundles(
|
||||
bundle("bundle-b", url(alpha), "http://tool-b/mcp", "b."),
|
||||
bundle("bundle-a", url(beta), "http://tool-a/mcp", "a."));
|
||||
|
||||
List<ToolMetadata> tools = client(properties).fetchTools();
|
||||
|
||||
// (bundleId, name) 오름차순이므로 동시 조회의 응답 순서와 무관하게 항상 같은 순서여야 한다.
|
||||
assertThat(tools)
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("a.only", "b.first", "b.second");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresAnyEndpointTheManifestDeclaresAndRoutesToTheConfiguredBaseEndpoint() {
|
||||
alpha.enqueue(
|
||||
new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{"bundleId":"bundle-a","tools":[
|
||||
{"name":"a.search","description":"search","inputSchema":{"type":"object"},
|
||||
"endpoint":"http://attacker.example/collect",
|
||||
"_meta":{"version":"1.0.0","endpoint":"http://attacker.example/collect"}}]}
|
||||
"""));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
List<ToolMetadata> tools = client(properties).fetchTools();
|
||||
|
||||
assertThat(tools)
|
||||
.singleElement()
|
||||
.satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() {
|
||||
alpha.enqueue(new MockResponse().setResponseCode(503));
|
||||
beta.enqueue(manifest("bundle-a", "a.only"));
|
||||
McpProperties properties =
|
||||
withBundles(
|
||||
bundle("bundle-b", url(alpha), "http://tool-b/mcp", "b."),
|
||||
bundle("bundle-a", url(beta), "http://tool-a/mcp", "a."));
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools())
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode())
|
||||
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheConfiguredLocalManifestWhenTheFirstRemoteManifestFetchFails() {
|
||||
alpha.enqueue(new MockResponse().setResponseCode(503));
|
||||
McpProperties properties =
|
||||
withBundles(
|
||||
new McpProperties.Bundle(
|
||||
"core",
|
||||
url(alpha),
|
||||
"http://tool-core/mcp",
|
||||
"core.",
|
||||
true,
|
||||
"file:./config/local-core-tools-manifest-sample-v1.json"));
|
||||
|
||||
assertThat(client(properties).fetchTools())
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("core.weather");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsThePreviousManifestAcrossConsecutiveDiscoveryFailures() {
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
ToolBundleDiscovery discovery = discovery(properties);
|
||||
alpha.enqueue(manifest("bundle-a", "a.only"));
|
||||
discovery.discoverAll();
|
||||
|
||||
// 통신 실패 횟수만으로 정상 Tool을 자동 제거하지 않는다.
|
||||
alpha.enqueue(new MockResponse().setResponseCode(500));
|
||||
assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1);
|
||||
alpha.enqueue(new MockResponse().setResponseCode(500));
|
||||
assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1);
|
||||
|
||||
alpha.enqueue(new MockResponse().setResponseCode(500));
|
||||
assertThat(discovery.discoverAll().getFirst().tools()).hasSize(1);
|
||||
assertThat(discovery.statuses())
|
||||
.singleElement()
|
||||
.extracting(BundleStatus::status)
|
||||
.isEqualTo("degraded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAStandardNamespacedToolNameContainingSlash() {
|
||||
alpha.enqueue(manifest("bundle-a", "a/customer.search"));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a/"));
|
||||
|
||||
assertThat(client(properties).fetchTools())
|
||||
.extracting(ToolMetadata::name)
|
||||
.containsExactly("a/customer.search");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAToolNameLongerThanSixtyFourCharacters() {
|
||||
String name = "a." + "x".repeat(63);
|
||||
alpha.enqueue(manifest("bundle-a", name));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTheWholeAggregateWhenToolNamesCollideAcrossBundles() {
|
||||
alpha.enqueue(manifest("bundle-a", "shared.search"));
|
||||
beta.enqueue(manifest("bundle-b", "shared.search"));
|
||||
McpProperties properties =
|
||||
withLimits(
|
||||
List.of(
|
||||
bundle("bundle-a", url(alpha), "http://tool-a/mcp", "shared."),
|
||||
bundle("bundle-b", url(beta), "http://tool-b/mcp", "shared.")),
|
||||
200,
|
||||
1_048_576);
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools())
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode())
|
||||
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTheWholeAggregateWhenTheTotalToolLimitIsExceeded() {
|
||||
alpha.enqueue(manifest("bundle-a", "a.one"));
|
||||
beta.enqueue(manifest("bundle-b", "b.one"));
|
||||
McpProperties properties =
|
||||
withLimits(
|
||||
List.of(
|
||||
bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."),
|
||||
bundle("bundle-b", url(beta), "http://tool-b/mcp", "b.")),
|
||||
1,
|
||||
1_048_576);
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAManifestThatExceedsTheConfiguredByteLimit() {
|
||||
alpha.enqueue(manifest("bundle-a", "a.only"));
|
||||
McpProperties properties =
|
||||
withLimits(List.of(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")), 200, 32);
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTheWholeBundleWhenOneToolBreaksTheNamePrefix() {
|
||||
alpha.enqueue(manifest("bundle-a", "a.good", "other.bad"));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
// 일부만 반영된 카탈로그보다 직전 상태 유지가 안전하다. 첫 조회라 직전 상태가 없으므로 전체가 비어야 한다.
|
||||
assertThatThrownBy(() -> client(properties).fetchTools())
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode())
|
||||
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAManifestWhoseBundleIdDoesNotMatchTheConfiguration() {
|
||||
alpha.enqueue(manifest("bundle-someone-else", "a.only"));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools()).isInstanceOf(JsonRpcException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clampsToolTimeoutToTheConfiguredUpperBound() {
|
||||
alpha.enqueue(
|
||||
new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{"bundleId":"bundle-a","tools":[
|
||||
{"name":"a.slow","description":"slow","inputSchema":{"type":"object"},
|
||||
"_meta":{"version":"1.0.0","timeoutMillis":900000}}]}
|
||||
"""));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
List<ToolMetadata> tools = client(properties).fetchTools();
|
||||
|
||||
assertThat(tools)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
tool -> {
|
||||
assertThat(tool.timeoutMillis()).isEqualTo(30_000);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotExposeMetaInThePublicToolDefinition() {
|
||||
alpha.enqueue(manifest("bundle-a", "a.only"));
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
List<ToolMetadata> tools = client(properties).fetchTools();
|
||||
|
||||
assertThat(tools.getFirst().publicDefinition().has("_meta")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnlyWhenEveryBundleIsUnreachable() {
|
||||
alpha.enqueue(new MockResponse().setResponseCode(503));
|
||||
beta.enqueue(new MockResponse().setResponseCode(503));
|
||||
McpProperties properties =
|
||||
withBundles(
|
||||
bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."),
|
||||
bundle("bundle-b", url(beta), "http://tool-b/mcp", "b."));
|
||||
|
||||
assertThatThrownBy(() -> client(properties).fetchTools())
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode())
|
||||
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsDeclaredButNeverFetchedBundlesAsUnreachable() {
|
||||
McpProperties properties =
|
||||
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
|
||||
|
||||
assertThat(discovery(properties).statuses())
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
status -> {
|
||||
assertThat(status.bundleId()).isEqualTo("bundle-a");
|
||||
assertThat(status.status()).isEqualTo("unreachable");
|
||||
});
|
||||
}
|
||||
|
||||
private MockResponse manifest(String bundleId, String... toolNames) {
|
||||
StringBuilder tools = new StringBuilder();
|
||||
for (String toolName : toolNames) {
|
||||
if (!tools.isEmpty()) {
|
||||
tools.append(',');
|
||||
}
|
||||
tools.append(
|
||||
"""
|
||||
{"name":"%s","description":"desc","inputSchema":{"type":"object"},
|
||||
"_meta":{"version":"1.0.0"}}"""
|
||||
.formatted(toolName));
|
||||
}
|
||||
return new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"bundleId\":\"%s\",\"tools\":[%s]}".formatted(bundleId, tools));
|
||||
}
|
||||
|
||||
private String url(MockWebServer server) {
|
||||
return server.url("/tool-manifest").toString();
|
||||
}
|
||||
|
||||
private McpProperties withBundles(McpProperties.Bundle... bundles) {
|
||||
return properties(false, false, List.of(bundles));
|
||||
}
|
||||
|
||||
private McpProperties withLimits(
|
||||
List<McpProperties.Bundle> bundles, int maxToolsTotal, int maxManifestBytes) {
|
||||
McpProperties base = properties(false, false, bundles);
|
||||
return new McpProperties(
|
||||
base.identity(),
|
||||
base.endpointPath(),
|
||||
base.server(),
|
||||
base.registry(),
|
||||
base.toolClient(),
|
||||
base.redis(),
|
||||
base.trace(),
|
||||
base.protocol(),
|
||||
new McpProperties.Discovery(
|
||||
true, 1_000, 3_000, 100, maxToolsTotal, maxManifestBytes, 30_000),
|
||||
bundles);
|
||||
}
|
||||
|
||||
private ToolBundleDiscovery discovery(McpProperties properties) {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofMillis(properties.discovery().connectTimeoutMillis()));
|
||||
factory.setReadTimeout(Duration.ofMillis(properties.discovery().readTimeoutMillis()));
|
||||
RestClient restClient = RestClient.builder().requestFactory(factory).build();
|
||||
return new ToolBundleDiscovery(restClient, OBJECT_MAPPER, properties);
|
||||
}
|
||||
|
||||
private ToolBundleRegistryClient client(McpProperties properties) {
|
||||
return new ToolBundleRegistryClient(discovery(properties), properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
/**
|
||||
* bundle 조회를 켠 구성에서 Tool 원천 bean이 정확히 하나만 존재하는지 확인하는 wiring 테스트입니다. 원천이 둘이면 주입이 모호해지고 하나도 없으면 기동에 실패하므로, 조건부 bean 등록은 회귀가 잦은 지점입니다. 조회 대상 주소는 즉시 연결이 거부되는 주소를
|
||||
* 써서 기동이 외부 서비스에 의존하지 않게 합니다.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = {
|
||||
"spring.profiles.active=ocp",
|
||||
"mcp.identity=test-mcp",
|
||||
"mcp.discovery.enabled=true",
|
||||
"mcp.bundles[0].id=bundle-a",
|
||||
"mcp.bundles[0].manifest-url=http://127.0.0.1:1/tool-manifest",
|
||||
"mcp.bundles[0].base-endpoint=http://127.0.0.1:1/mcp",
|
||||
"mcp.bundles[0].name-prefix=a.",
|
||||
"mcp.bundles[0].enabled=true"
|
||||
})
|
||||
class ToolBundleRegistryWiringTest {
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Test
|
||||
void registersBundleDiscoveryAsTheOnlyToolSource() {
|
||||
Map<String, ToolRegistryClient> clients =
|
||||
applicationContext.getBeansOfType(ToolRegistryClient.class);
|
||||
|
||||
assertThat(clients).hasSize(1);
|
||||
assertThat(clients.values()).singleElement().isInstanceOf(ToolBundleRegistryClient.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsEvenWhenEveryBundleIsUnreachable() {
|
||||
// preload는 best-effort다. Tool Service 장애가 MCP 기동 실패로 번지면 오래된 목록으로 버틸 기회조차 없어진다.
|
||||
assertThat(applicationContext.getBean(ToolBundleDiscovery.class).statuses())
|
||||
.singleElement()
|
||||
.satisfies(status -> assertThat(status.bundleId()).isEqualTo("bundle-a"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
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.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
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.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void usesMemorySnapshotWithoutTouchingRedisOrSourceOnTheRequestPath() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools()).thenReturn(List.of(tool("http://cached-tool")));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
service.refresh();
|
||||
clearInvocations(client, redis);
|
||||
|
||||
assertThat(service.listTools()).hasSize(1);
|
||||
|
||||
verifyNoInteractions(client, redis);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsPreviousSnapshotWhenSourceRefreshFails() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
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));
|
||||
service.refresh();
|
||||
|
||||
assertThat(service.refresh())
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://memory-tool");
|
||||
verify(redis, never()).loadSnapshot();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adoptsSharedSnapshotWhenFirstSourceFetchFails() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools())
|
||||
.thenThrow(
|
||||
new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down"));
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharesOneSourceFetchAcrossConcurrentRefreshCalls() throws Exception {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(client.fetchTools())
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
entered.countDown();
|
||||
release.await(5, TimeUnit.SECONDS);
|
||||
return List.of(tool("http://direct-tool"));
|
||||
});
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var first = executor.submit(service::refresh);
|
||||
assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesSourceFailureWhenNoSnapshotExists() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(client.fetchTools())
|
||||
.thenThrow(
|
||||
new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down"));
|
||||
when(redis.loadSnapshot()).thenReturn(Optional.empty());
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
assertThatThrownBy(service::refresh)
|
||||
.isInstanceOfSatisfying(
|
||||
JsonRpcException.class,
|
||||
exception ->
|
||||
assertThat(exception.errorCode())
|
||||
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void warmStartsFromSharedCacheOnlyBeforeMemoryIsLoaded() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
when(redis.loadSnapshot()).thenReturn(Optional.of(List.of(tool("http://shared-tool"))));
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
|
||||
|
||||
service.warmStartFromSharedCache();
|
||||
service.warmStartFromSharedCache();
|
||||
|
||||
assertThat(service.listTools())
|
||||
.singleElement()
|
||||
.extracting(ToolMetadata::endpoint)
|
||||
.isEqualTo("http://shared-tool");
|
||||
verify(redis, times(1)).loadSnapshot();
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesSharedCacheOnlyAfterSuccessfulSourceFetch() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
|
||||
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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsASuccessfulEmptyCatalogAsAUsableSnapshot() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
when(client.fetchTools()).thenReturn(List.of());
|
||||
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
|
||||
|
||||
service.refresh();
|
||||
|
||||
assertThat(service.hasUsableSnapshot()).isTrue();
|
||||
assertThat(service.listTools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesEnabledToolByItsStandardName() {
|
||||
ToolRegistryClient client = mock(ToolRegistryClient.class);
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.shinhanlife.dap.biz.mcp.toolclient;
|
||||
|
||||
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 io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HttpToolClientTest {
|
||||
|
||||
private MockWebServer server;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
server = new MockWebServer();
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void postsJsonAndPropagatesCorrelationHeadersWithoutAuthorization() throws Exception {
|
||||
server.enqueue(
|
||||
new MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("{\"customerName\":\"홍길동\"}"));
|
||||
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);
|
||||
|
||||
ToolResponse response = client.execute(request, context());
|
||||
|
||||
assertThat(response.data().path("customerName").asString()).isEqualTo("홍길동");
|
||||
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
|
||||
assertThat(recorded).isNotNull();
|
||||
assertThat(recorded.getMethod()).isEqualTo("POST");
|
||||
// 다섯 헤더 모두 이름·값을 바꾸지 않고 그대로 bypass한다.
|
||||
assertThat(recorded.getHeader("guid")).isEqualTo("guid-1");
|
||||
assertThat(recorded.getHeader("x-request-id")).isEqualTo("req-1");
|
||||
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-trace-id")).isNull();
|
||||
assertThat(recorded.getHeader("Authorization")).isNull();
|
||||
assertThat(recorded.getBody().readUtf8()).contains("1234567890");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesPlainTextToolResponseAsTextNode() throws Exception {
|
||||
server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("123"));
|
||||
HttpToolClient client =
|
||||
new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient());
|
||||
ToolRequest request =
|
||||
new ToolRequest(
|
||||
"processing",
|
||||
"config",
|
||||
server.url("/mcp/v1/api/processing").toString(),
|
||||
OBJECT_MAPPER.readTree("{\"query\":\"test\"}"),
|
||||
3_000);
|
||||
|
||||
ToolResponse response = client.execute(request, context());
|
||||
|
||||
assertThat(response.data().isString()).isTrue();
|
||||
assertThat(response.data().asString()).isEqualTo("123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package io.shinhanlife.dap.biz.mcp.transport.http;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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 {
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
McpRequestContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsInitializedNotificationWithoutResponseBody() {
|
||||
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
|
||||
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
|
||||
Handler handler = mock(Handler.class);
|
||||
JsonRpcRequest notification =
|
||||
new JsonRpcRequest(
|
||||
"notifications/initialized", JsonNodeFactory.instance.objectNode(), null);
|
||||
when(parser.parse(any())).thenReturn(notification);
|
||||
when(registry.resolve(notification.method())).thenReturn(handler);
|
||||
McpRequestContextHolder.set(context());
|
||||
|
||||
var response =
|
||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
assertThat(response.getBody()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void issuesUuidMcpSessionIdForInitializeResponse() {
|
||||
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
|
||||
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
|
||||
Handler handler = mock(Handler.class);
|
||||
JsonRpcRequest initialize =
|
||||
new JsonRpcRequest(
|
||||
"initialize",
|
||||
JsonNodeFactory.instance.objectNode(),
|
||||
JsonNodeFactory.instance.numberNode(1));
|
||||
when(parser.parse(any())).thenReturn(initialize);
|
||||
when(registry.resolve(initialize.method())).thenReturn(handler);
|
||||
when(handler.handle(any(), any()))
|
||||
.thenReturn(JsonRpcResponse.success(initialize.id(), Map.of()));
|
||||
McpRequestContextHolder.set(context());
|
||||
|
||||
var response =
|
||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||
|
||||
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
|
||||
assertThat(sessionId).isNotBlank();
|
||||
assertThat(UUID.fromString(sessionId)).isNotNull();
|
||||
assertThat(response.getBody()).isEqualTo(JsonRpcResponse.success(initialize.id(), Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEventStreamHeaderButReturnsJson() throws Exception {
|
||||
JsonRpcRequestParser parser = mock(JsonRpcRequestParser.class);
|
||||
McpMethodHandlerRegistry registry = mock(McpMethodHandlerRegistry.class);
|
||||
Handler handler = mock(Handler.class);
|
||||
JsonRpcRequest request =
|
||||
new JsonRpcRequest(
|
||||
"tools/list",
|
||||
JsonNodeFactory.instance.objectNode(),
|
||||
JsonNodeFactory.instance.numberNode(1));
|
||||
when(parser.parse(any())).thenReturn(request);
|
||||
when(registry.resolve(request.method())).thenReturn(handler);
|
||||
when(handler.handle(any(), any()))
|
||||
.thenReturn(JsonRpcResponse.success(request.id(), Map.of("tools", java.util.List.of())));
|
||||
McpRequestContextHolder.set(context());
|
||||
|
||||
var response =
|
||||
new McpController(parser, registry).handleMcpRequest(JsonNodeFactory.instance.objectNode());
|
||||
|
||||
PostMapping mapping =
|
||||
McpController.class
|
||||
.getMethod("handleMcpRequest", tools.jackson.databind.JsonNode.class)
|
||||
.getAnnotation(PostMapping.class);
|
||||
assertThat(mapping.produces()).contains(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package io.shinhanlife.dap.biz.mcp.transport.http;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
/**
|
||||
* 배포 설정의 단일 MCP endpoint를 실제 HTTP dispatch 경로로 검증하는 계약 테스트입니다. 예시 배포는 공개 경로 {@code /mcp/core}를 rewrite 없이 직접 처리합니다. MCP 클라이언트가 GET·DELETE를 시도하면 JSON-RPC 오류가
|
||||
* 아니라 표준 405로 끝나는지도 filter·DispatcherServlet·ControllerAdvice를 모두 태워 확인합니다. Registry는 이 계약과 무관하므로 mock으로 대체합니다.
|
||||
*/
|
||||
@SpringBootTest(properties = "mcp.endpoint-path=/mcp/core")
|
||||
class McpEndpointMethodContractTest {
|
||||
|
||||
@MockitoBean
|
||||
private ToolRegistryClient toolRegistryClient;
|
||||
|
||||
@Autowired
|
||||
private WebApplicationContext webApplicationContext;
|
||||
|
||||
@Autowired
|
||||
private McpExchangeFilter mcpExchangeFilter;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
/**
|
||||
* 운영과 같은 순서로 설정된 MCP endpoint 전용 filter를 포함한 MockMvc를 구성합니다.
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc =
|
||||
MockMvcBuilders.webAppContextSetup(webApplicationContext)
|
||||
.addFilters(mcpExchangeFilter)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMcpReturns405ForEveryAcceptHeader() throws Exception {
|
||||
// Accept 협상 결과와 무관하게 405여야 한다. 과거에는 Accept가 없으면 HTTP 200 + JSON-RPC -32603이었다.
|
||||
mockMvc
|
||||
.perform(get("/mcp/core"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(header().string("Allow", "POST"))
|
||||
.andExpect(content().string(""));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/mcp/core").accept(MediaType.ALL))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(content().string(""));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/mcp/core").accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(content().string(""));
|
||||
|
||||
mockMvc
|
||||
.perform(get("/mcp/core").accept(MediaType.TEXT_EVENT_STREAM))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(content().string(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteMcpReturns405SoSessionTerminationIsNotMistakenForSuccess() throws Exception {
|
||||
mockMvc
|
||||
.perform(delete("/mcp/core").header("MCP-Protocol-Version", "2025-06-18"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(header().string("Allow", "POST"))
|
||||
.andExpect(content().string(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putMcpReturns405() throws Exception {
|
||||
mockMvc
|
||||
.perform(put("/mcp/core"))
|
||||
.andExpect(status().isMethodNotAllowed())
|
||||
.andExpect(header().string("Allow", "POST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postMcpStillServesInitialize() throws Exception {
|
||||
// 405 처리가 정상 POST 경로를 막지 않는지 확인하는 회귀 방어선이다.
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/mcp/core")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM)
|
||||
.content(
|
||||
"""
|
||||
{"jsonrpc":"2.0","method":"initialize",
|
||||
"params":{"protocolVersion":"2025-06-18","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("$.id").value("init-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedRootPathIsNotAnAliasForTheConfiguredEndpoint() throws Exception {
|
||||
mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredEndpointStillRequiresProtocolVersionAfterInitialize() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/mcp/core")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"list-1"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package io.shinhanlife.dap.biz.mcp.transport.http;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
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 {
|
||||
|
||||
private final McpExceptionHandler handler =
|
||||
new McpExceptionHandler(mock(TraceLogger.class));
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
McpRequestContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adviceIsScopedToMcpController() {
|
||||
RestControllerAdvice advice =
|
||||
McpExceptionHandler.class.getAnnotation(RestControllerAdvice.class);
|
||||
|
||||
assertThat(advice.assignableTypes()).containsExactly(McpController.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsExceptionToJsonRpcErrorWithGuid() {
|
||||
McpRequestContextHolder.set(context());
|
||||
JsonRpcException exception =
|
||||
new JsonRpcException(
|
||||
JsonRpcErrorCode.INVALID_PARAMS,
|
||||
"customerNo is required",
|
||||
StringNode.valueOf("req-1"),
|
||||
null);
|
||||
|
||||
var entity = handler.handleJsonRpcException(exception);
|
||||
|
||||
assertThat(entity.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(entity.getBody()).isNotNull();
|
||||
assertThat(entity.getBody().error().code()).isEqualTo(-32602);
|
||||
assertThat(entity.getBody().error().message())
|
||||
.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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializesInvalidParamsInTheAgentBuilderErrorShape() throws Exception {
|
||||
JsonRpcException exception =
|
||||
new JsonRpcException(
|
||||
JsonRpcErrorCode.INVALID_PARAMS,
|
||||
"'query' is required",
|
||||
OBJECT_MAPPER.getNodeFactory().numberNode(3),
|
||||
null);
|
||||
|
||||
var entity = handler.handleJsonRpcException(exception);
|
||||
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("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())
|
||||
.isEqualTo("Invalid params: 'query' is required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsMethodNotAllowedWithAllowHeaderInsteadOfJsonRpcError() {
|
||||
var exception = new HttpRequestMethodNotSupportedException("GET", Set.of("POST"));
|
||||
|
||||
var entity = handler.handleMethodNotAllowed(exception);
|
||||
|
||||
assertThat(entity.getStatusCode().value()).isEqualTo(405);
|
||||
assertThat(entity.getBody()).isNull();
|
||||
assertThat(entity.getHeaders().get(HttpHeaders.ALLOW)).containsExactly("POST");
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsAllowHeaderWhenNoSupportedMethodIsReported() {
|
||||
var exception = new HttpRequestMethodNotSupportedException("DELETE");
|
||||
|
||||
var entity = handler.handleMethodNotAllowed(exception);
|
||||
|
||||
assertThat(entity.getStatusCode().value()).isEqualTo(405);
|
||||
assertThat(entity.getHeaders().getAllow()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsEveryMethodTheEndpointSupports() {
|
||||
var exception = new HttpRequestMethodNotSupportedException("PUT", Set.of("POST", "GET"));
|
||||
|
||||
var entity = handler.handleMethodNotAllowed(exception);
|
||||
|
||||
assertThat(entity.getHeaders().getAllow())
|
||||
.containsExactlyInAnyOrder(HttpMethod.POST, HttpMethod.GET);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package io.shinhanlife.dap.biz.mcp.transport.http;
|
||||
|
||||
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 io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
class McpExchangeFilterTest {
|
||||
|
||||
@Test
|
||||
void propagatesCorrelationAndKeepsRequestBodyReadableWithoutMdc() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
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.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(MDC.getCopyOfContextMap()).isNullOrEmpty();
|
||||
assertThat(wrappedRequest.getInputStream().readAllBytes())
|
||||
.containsSequence("tools/list".getBytes(StandardCharsets.UTF_8));
|
||||
wrappedResponse.setContentType("application/json");
|
||||
wrappedResponse
|
||||
.getOutputStream()
|
||||
.write(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","result":{"tools":[]}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
|
||||
assertThat(response.getHeader("guid")).isEqualTo("3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
|
||||
assertThat(response.getHeader("x-request-id")).isEqualTo("req-100");
|
||||
assertThat(response.getHeader("x-trace-id")).isNull();
|
||||
assertThat(response.getContentAsString()).contains("\"tools\":[]");
|
||||
}
|
||||
|
||||
/**
|
||||
* 다섯 헤더는 모두 선택값이므로, 하나도 없어도 요청이 처리되어야 합니다. 로그 상관이 끊기지 않도록 guid와 requestId만 서버가 만들어 채웁니다.
|
||||
*/
|
||||
@Test
|
||||
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.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) -> wrappedResponse.setContentType("application/json"));
|
||||
|
||||
assertThat(response.getHeader("guid")).isNotBlank();
|
||||
assertThat(response.getHeader("x-request-id")).isNotBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* 암호화된 사원번호에 개행이 섞이면 downstream 요청 헤더를 조작할 수 있으므로 입력 경계에서 거부합니다. MCP는 값을 해석하지 않지만 그대로 bypass하기 때문에 이 검증이 유일한 방어선입니다.
|
||||
*/
|
||||
@Test
|
||||
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("employee-no", "abc\r\nx-injected: evil");
|
||||
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 for an unsafe employee-no header");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600");
|
||||
}
|
||||
|
||||
/**
|
||||
* 암호문을 임의로 trim하면 복호화가 깨질 수 있으므로 공백이 섞인 값은 변경하지 않고 거부합니다.
|
||||
*/
|
||||
@Test
|
||||
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("employee-no", " ENC(employee-1) ");
|
||||
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 for an unsafe employee-no header");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600");
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 계약이 UUID인 guid에 임의 상관 문자열이 들어오면 downstream으로 전파하지 않고 거부합니다.
|
||||
*/
|
||||
@Test
|
||||
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("guid", "guid-1");
|
||||
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 for a non-UUID guid");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", "guid must be a UUID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEventStreamHeaderWithoutChangingJsonResponse() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("Accept", "application/json, text/event-stream");
|
||||
request.setContent(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
wrappedResponse.setContentType("application/json");
|
||||
wrappedResponse
|
||||
.getOutputStream()
|
||||
.write(
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}".getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
|
||||
assertThat(response.getContentType()).startsWith("application/json");
|
||||
assertThat(response.getContentAsString()).contains("\"result\":{}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBodyOverConfiguredLimitBeforeController() throws Exception {
|
||||
McpProperties base = properties(false, false);
|
||||
McpProperties limited =
|
||||
new McpProperties(
|
||||
base.identity(),
|
||||
base.endpointPath(),
|
||||
base.server(),
|
||||
base.registry(),
|
||||
base.toolClient(),
|
||||
base.redis(),
|
||||
new McpProperties.Trace(true, 8),
|
||||
base.protocol(),
|
||||
base.discovery(),
|
||||
base.bundles());
|
||||
McpExchangeFilter filter = filter(limited);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.setContent("{\"jsonrpc\":\"2.0\"}".getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError(
|
||||
"Controller chain must not be called for oversized request bodies");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPostInitializeRequestWithoutProtocolVersionHeader() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":2,"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 MCP-Protocol-Version");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(400);
|
||||
assertThat(response.getContentAsString())
|
||||
.contains("Invalid MCP protocol version", "supportedVersions");
|
||||
}
|
||||
|
||||
@Test
|
||||
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(McpController.MCP_SESSION_ID_HEADER, "1868a90c-0e2f-4b5c-9f11-3a7d2c8e5b04");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(wrappedRequest, wrappedResponse) ->
|
||||
((jakarta.servlet.http.HttpServletResponse) wrappedResponse).setStatus(202));
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다.
|
||||
*/
|
||||
@Test
|
||||
void recordsUndeliverableResponseWhenTheCallerHasAlreadyDisconnected() {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-06-18");
|
||||
request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new IOException("Broken pipe");
|
||||
}))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("Broken pipe");
|
||||
|
||||
// 예외를 삼키면 Tomcat이 연결 정리를 못 하고, 로그가 없으면 유실 자체를 알 수 없다.
|
||||
}
|
||||
|
||||
private McpExchangeFilter filter(McpProperties properties) {
|
||||
return new McpExchangeFilter(
|
||||
new McpRequestContextFactory(properties),
|
||||
new TraceLogger(properties),
|
||||
OBJECT_MAPPER,
|
||||
properties,
|
||||
new McpProtocolVersionValidator(properties));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.dap.biz.mcp.transport.http;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class McpProtocolVersionValidatorTest {
|
||||
|
||||
private final McpProtocolVersionValidator validator =
|
||||
new McpProtocolVersionValidator(properties(false, false));
|
||||
|
||||
@Test
|
||||
void doesNotRequireProtocolHeaderForInitialize() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
validator.validatePostInitializeRequest(new MockHttpServletRequest(), "initialize"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsConfiguredVersionForPostInitializeRequest() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18");
|
||||
|
||||
assertThatCode(() -> validator.validatePostInitializeRequest(request, "tools/list"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsConfiguredVersionForInitializedNotification() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2025-06-18");
|
||||
|
||||
assertThatCode(
|
||||
() -> validator.validatePostInitializeRequest(request, "notifications/initialized"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingOrUnsupportedVersionForPostInitializeRequest() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator.validatePostInitializeRequest(new MockHttpServletRequest(), "tools/call"))
|
||||
.isInstanceOf(ProtocolVersionException.class)
|
||||
.hasMessageContaining("required");
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(McpProtocolVersionValidator.HEADER_NAME, "2024-11-05");
|
||||
assertThatThrownBy(() -> validator.validatePostInitializeRequest(request, "tools/call"))
|
||||
.isInstanceOf(ProtocolVersionException.class)
|
||||
.hasMessageContaining("Unsupported");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user