Initial commit

This commit is contained in:
2026-08-05 15:54:25 +09:00
commit a4eb5a580f
168 changed files with 12057 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.biz.mcp;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* AX HUB MCP Server의 Spring Boot 애플리케이션 시작점입니다. HTTP 요청을 직접 처리하지 않고 component scan, configuration properties, scheduler를 활성화해
* transport·method·registry·execute·observability 구성요소를 조립합니다. 주요 의존성은 Spring Boot 자동 구성, {@code McpProperties} 설정 객체, MCP SDK의 JSON Schema 검증기이며 실행 인자는 Spring
* 컨테이너로 전달됩니다.
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class McpServerApplication {
/**
* Spring Boot 애플리케이션을 시작하는 최초 진입점입니다. 전달받은 실행 인자를 Spring에 넘기고 component scan과 설정 로딩을 시작합니다.
*/
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
/**
* Tool inputSchema와 arguments를 JSON Schema 2020-12 기준으로 검증할 MCP SDK 검증기를 한 번 생성합니다. Tool 실행 전 검증 계층에서만 사용하며 MCP HTTP transport나 서버 lifecycle을 자동 구성하지
* 않습니다.
*
* @return schema 컴파일 결과를 재사용하는 MCP SDK 검증기
*/
@Bean
JsonSchemaValidator mcpJsonSchemaValidator() {
return new DefaultJsonSchemaValidator();
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dap.biz.mcp.config;
import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. MCP 요청을 직접 처리하지 않으며 discovery와 {@code HttpToolClient}가 주입받을 연결·timeout 기본값을
* 제공합니다. 주요 의존성은 {@link McpProperties}, Spring RestClient 및 JDK HttpClient입니다.
*/
@Configuration
public class HttpClientConfig {
/**
* 여러 Tool 호출이 TCP 연결을 재사용할 수 있도록 공유 JDK HTTP client를 만듭니다. Tool별 read timeout은 이 객체를 새로 만들지 않고 HttpToolClient 쪽에서 적용합니다.
*/
@Bean
@Qualifier("toolHttpClient")
HttpClient toolHttpClient(McpProperties properties) {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(properties.toolClient().connectTimeoutMillis()))
.build();
}
/**
* Tool Service bundle 매니페스트 조회 전용 RestClient를 생성합니다. 조회 대상이 bundle마다 다르므로 base URL을 두지 않고 매 호출에서 전체 {@code manifestUrl}을 사용합니다. timeout은 Tool 실행보다 짧게 잡아,
* 느린 bundle 하나가 전체 조회 주기를 잡아먹지 않게 합니다.
*/
@Bean
@Qualifier("manifestRestClient")
RestClient manifestRestClient(McpProperties properties) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
McpProperties.Discovery discovery = properties.discovery();
factory.setConnectTimeout(
Duration.ofMillis(discovery == null ? 1_000 : discovery.connectTimeoutMillis()));
factory.setReadTimeout(
Duration.ofMillis(discovery == null ? 3_000 : discovery.readTimeoutMillis()));
return RestClient.builder().requestFactory(factory).build();
}
}

View File

@@ -0,0 +1,173 @@
package io.shinhanlife.dap.biz.mcp.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* {@code application.yml}의 {@code mcp.*} 설정을 타입 안전한 불변 객체로 묶고 시작 시 유효성을 검증하는 구성 계약입니다. MCP 요청을 직접 처리하지 않으며 공개 endpoint, HTTP transport, Registry, Tool client와
* observability 구성요소가 각자의 설정만 읽습니다. 주요 의존성은 Spring Boot ConfigurationProperties와 Jakarta Validation이며, 중첩 record는 서버·연동·cache·trace 정책을 분리합니다.
*/
@Validated
@ConfigurationProperties(prefix = "mcp")
public record McpProperties(
@NotBlank String identity,
@NotBlank
@Pattern(
regexp = "^/mcp(?:/[a-z0-9-]+)?$",
message = "must be /mcp or /mcp/<lowercase-alphanumeric-hyphen>")
String endpointPath,
@Valid Server server,
@Valid Registry registry,
@Valid ToolClient toolClient,
@Valid Redis redis,
@Valid Trace trace,
@Valid Protocol protocol,
@Valid Discovery discovery,
List<@Valid Bundle> bundles) {
/**
* 선언되지 않은 bundle 목록을 빈 목록으로 정규화해 이후 코드가 null을 검사하지 않게 합니다.
*/
public McpProperties {
bundles = bundles == null ? List.of() : List.copyOf(bundles);
}
/**
* 조회 대상으로 켜져 있는 bundle만 골라 반환합니다. 조회·병합·Actuator 상태가 같은 목록을 사용합니다.
*/
public List<Bundle> enabledBundles() {
return bundles.stream().filter(Bundle::enabled).toList();
}
/**
* bundle 조회를 켰는데 조회 대상이 하나도 없으면 기동에 실패시킵니다. 이 상태로 기동하면 {@code tools/list}가 영구히 비어 있으므로 운영 중 발견하는 것보다 기동 실패가 낫습니다.
*/
@AssertTrue(message = "mcp.discovery.enabled=true requires at least one entry in mcp.bundles")
public boolean isDiscoveryTargetDeclared() {
return discovery == null || !discovery.enabled() || !enabledBundles().isEmpty();
}
/**
* bundle {@code id}와 {@code namePrefix}가 서로 충돌하지 않는지 기동 시 검증합니다. prefix가 다른 prefix의 접두사이면(예: {@code a.}와 {@code a.b.}) Tool 이름이 어느 bundle 소속인지 확정되지 않아 라우팅
* 대상이 흔들리므로 이 조합을 금지합니다.
*/
@AssertTrue(
message =
"mcp.bundles id and namePrefix must be unique, and no namePrefix may be a prefix of another")
public boolean isBundleRoutingUnambiguous() {
List<String> ids = bundles.stream().map(Bundle::id).toList();
if (ids.size() != ids.stream().distinct().count()) {
return false;
}
List<String> prefixes =
bundles.stream()
.map(Bundle::namePrefix)
.filter(prefix -> prefix != null && !prefix.isBlank())
.toList();
if (prefixes.size() != prefixes.stream().distinct().count()) {
return false;
}
for (String outer : prefixes) {
for (String inner : prefixes) {
if (outer != inner && inner.startsWith(outer)) {
return false;
}
}
}
return true;
}
/**
* initialize 응답에 공개할 MCP 서버 식별 정보 설정입니다.
*/
public record Server(@NotBlank String name, @NotBlank String title, @NotBlank String version) {
}
/**
* local JSON fixture 위치와 Tool Service refresh 주기·분산 지연 설정입니다.
*/
public record Registry(
@NotBlank String localToolFile,
@Min(1) long refreshIntervalSeconds,
@Min(0) long refreshJitterSeconds) {
}
/**
* Tool Service 호출의 timeout과 header 전달 정책 설정입니다.
*/
public record ToolClient(
@Min(1) int connectTimeoutMillis,
@Min(1) int readTimeoutMillis,
@Min(1) long requestDeadlineMillis,
boolean forwardAuthorization) {
}
/**
* 선택적 Redis Tool Registry cache의 활성화 여부와 key namespace 설정입니다.
*/
public record Redis(boolean enabled, @NotBlank String keyPrefix) {
}
/**
* Tool Service bundle 매니페스트 주기 조회의 timeout과 상한 정책 설정입니다. 상한값은 잘못 구성된 bundle 하나가 전체 카탈로그를 부풀리거나 Tool timeout을 무한정 늘리는 것을 막는 방어선입니다.
*/
public record Discovery(
boolean enabled,
@Min(1) int connectTimeoutMillis,
@Min(1) int readTimeoutMillis,
@Min(1) int maxToolsPerBundle,
@Min(1) int maxToolsTotal,
@Min(1) int maxManifestBytes,
@Min(1) int maxToolTimeoutMillis) {
}
/**
* 이 MCP에 속하는 Tool Service 한 묶음의 조회 주소와 실행 주소 설정입니다. {@code baseEndpoint}는 설정에서만 오며 매니페스트 응답이 바꿀 수 없습니다. {@code fallbackManifestFile}은 최초 원격 조회 실패 시에만 쓰는
* local 검증용 원천입니다.
*/
public record Bundle(
@NotBlank String id,
@NotBlank String manifestUrl,
@NotBlank String baseEndpoint,
@NotBlank String namePrefix,
boolean enabled,
String fallbackManifestFile) {
}
/**
* MCP 경계 로그 활성화와 수신 요청 최대 크기 정책 설정입니다.
*/
public record Trace(boolean enabled, @Min(1) int maxBodyBytes) {
}
/**
* initialize 협상 및 이후 요청 헤더 검증에 사용할 MCP protocol version 정책 설정입니다.
*/
public record Protocol(
@NotEmpty List<@NotBlank String> supportedVersions, @NotBlank String preferredVersion) {
/**
* 외부에서 받은 지원 버전 목록을 복사해 설정 객체가 생성된 뒤 바뀌지 않게 합니다.
*/
public Protocol {
supportedVersions = List.copyOf(supportedVersions);
}
/**
* preferred version이 실제 지원 목록에도 포함되는지 설정 로딩 시 검증합니다.
*/
@AssertTrue(message = "preferredVersion must be included in supportedVersions")
public boolean isPreferredVersionSupported() {
return supportedVersions.contains(preferredVersion);
}
}
}

View File

@@ -0,0 +1,42 @@
package io.shinhanlife.dap.biz.mcp.context;
import java.time.Duration;
import java.time.Instant;
/**
* 하나의 MCP HTTP 요청 전체에서 공유할 correlation·호출자·deadline 정보를 담는 불변 context입니다. {@link McpRequestContextFactory}가 만들고 HTTP transport, method handler, Tool client,
* observability 계층이 사용하며 서버 대화 상태를 저장하지 않습니다. {@code guid}는 요청 하나를 끝까지 따라가는 상관 값이고 {@code requestId}는 개별 HTTP 요청 식별자입니다. {@code employeeNo}와
* {@code virtualEmployeeNo}는 호출자가 암호화해 보낸
* <b>불투명 값</b>입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다.
*/
public record McpRequestContext(
String requestId,
String guid,
String mcpSessionId,
String employeeNo,
String virtualEmployeeNo,
String authorization,
Instant deadline) {
/**
* deadline이 없는 context를 허용하되 <b>이미 만료된 것으로</b> 취급합니다.
*
* <p>정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우므로 null이 올 수 없습니다. 그래도 null을 현재 시각으로 바꾸는
* 이유는, 만약 잘못 만들어진 context가 흘러들어오면 {@link #remainingMillis()}가 0 이하가 되어 Tool 호출이 즉시 중단되기 때문입니다. 시간 제한 없이 무한정 호출되는 것보다 안전한 쪽으로 실패합니다.
*/
public McpRequestContext {
deadline = deadline == null ? Instant.now() : deadline;
}
/**
* 이 요청에 남은 시간을 밀리초로 알려 줍니다.
*
* <p>Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. 이미
* 시간이 다 됐으면 0 이하가 되고, 그때는 Tool을 호출하지 않고 timeout으로 끝냅니다.
*
* <p>이 예산은 Agent Builder가 연결을 끊는 시각보다 <b>짧아야</b> 합니다. 같거나 길면 MCP가 응답을 만들어도 받을 상대가 이미 사라진 뒤입니다.
*/
public long remainingMillis() {
return Duration.between(Instant.now(), deadline).toMillis();
}
}

View File

@@ -0,0 +1,47 @@
package io.shinhanlife.dap.biz.mcp.context;
import java.util.Optional;
/**
* 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. {@code McpExchangeFilter}가 설정하고 정상·예외 완료 시 제거합니다. 요청 밖에서 context를 보관하거나 서버 세션 상태로
* 사용하면 안 되는 correlation 전용 유틸리티입니다.
*/
public final class McpRequestContextHolder {
private static final ThreadLocal<McpRequestContext> CONTEXT = new ThreadLocal<>();
/**
* 인스턴스를 만들 수 없는 정적 유틸리티 클래스임을 명확히 합니다.
*/
private McpRequestContextHolder() {
}
/**
* 현재 요청을 처리하는 thread에 request context를 저장합니다.
*/
public static void set(McpRequestContext context) {
CONTEXT.set(context);
}
/**
* 현재 thread의 request context를 Optional로 안전하게 조회합니다.
*/
public static Optional<McpRequestContext> get() {
return Optional.ofNullable(CONTEXT.get());
}
/**
* 반드시 context가 있어야 하는 처리 단계에서 값을 반환합니다. filter 밖에서 잘못 호출하면 즉시 예외를 발생시켜 잘못된 correlation 처리를 막습니다.
*/
public static McpRequestContext require() {
return get()
.orElseThrow(() -> new IllegalStateException("MCP request context is not available"));
}
/**
* 요청이 끝난 뒤 ThreadLocal 값을 제거하여 다음 요청에 정보가 섞이지 않게 합니다.
*/
public static void clear() {
CONTEXT.remove();
}
}

View File

@@ -0,0 +1,121 @@
package io.shinhanlife.dap.biz.mcp.execute;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
import java.util.Map;
import org.springframework.stereotype.Component;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid
* params 오류로 끝냅니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인
* {@link ToolCall}입니다.
*/
@Component
public class ToolArgumentValidator {
private final ObjectMapper objectMapper;
private final JsonSchemaValidator jsonSchemaValidator;
/**
* JSON 변환용 Jackson mapper와 MCP SDK JSON Schema 검증기를 주입받습니다. 검증기는 Spring singleton으로 생성되어 동일한 Tool schema 컴파일 결과를 재사용합니다.
*/
public ToolArgumentValidator(ObjectMapper objectMapper, JsonSchemaValidator jsonSchemaValidator) {
this.objectMapper = objectMapper;
this.jsonSchemaValidator = jsonSchemaValidator;
}
/**
* Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반
* 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다.
*/
public void validate(ToolCall call, ToolMetadata metadata) {
validateInputSchema(call, metadata.inputSchema());
}
/**
* Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다.
* SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다.
*/
private void validateInputSchema(ToolCall call, JsonNode schema) {
if (schema == null || schema.isNull()) {
return;
}
validateStableContract(call, schema);
@SuppressWarnings("unchecked")
Map<String, Object> schemaMap = objectMapper.convertValue(schema, Map.class);
Object arguments = objectMapper.convertValue(call.arguments(), Object.class);
JsonSchemaValidator.ValidationResponse validation =
jsonSchemaValidator.validate(schemaMap, arguments);
if (!validation.valid()) {
throw invalid("arguments do not match inputSchema");
}
}
/**
* 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가
* 담당합니다.
*/
private void validateStableContract(ToolCall call, JsonNode schema) {
if (schema.has("type") && !"object".equals(schema.path("type").asString())) {
throw invalid("Only object inputSchema is supported by this adapter");
}
JsonNode required = schema.path("required");
if (required.isArray()) {
required.forEach(
field -> {
String name = field.asString();
if (!call.arguments().has(name) || call.arguments().get(name).isNull()) {
throw invalid("'" + name + "' is required");
}
});
}
JsonNode properties = schema.path("properties");
if (properties.isObject()) {
properties
.properties()
.forEach(
entry -> {
JsonNode value = call.arguments().get(entry.getKey());
if (value != null && !value.isNull()) {
validateStableType(
entry.getKey(), entry.getValue().path("type").asString(null), value);
}
});
}
}
/**
* 기존 기본 JSON 타입 오류 문구를 보존하면서 각 arguments 값의 선언 타입을 검사합니다.
*/
private void validateStableType(String field, String type, JsonNode value) {
if (type == null) {
return;
}
boolean valid =
switch (type) {
case "string" -> value.isString();
case "integer" -> value.isIntegralNumber();
case "number" -> value.isNumber();
case "boolean" -> value.isBoolean();
case "object" -> value.isObject();
case "array" -> value.isArray();
default -> true;
};
if (!valid) {
throw invalid(field + " must be of type " + type);
}
}
/**
* 검증 실패 이유를 Invalid params JSON-RPC 예외로 통일합니다.
*/
private JsonRpcException invalid(String details) {
return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details);
}
}

View File

@@ -0,0 +1,10 @@
package io.shinhanlife.dap.biz.mcp.execute;
import tools.jackson.databind.JsonNode;
/**
* MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON
* 구조를 보존해 이후 schema 검증과 Tool Service 호출에 사용합니다.
*/
public record ToolCall(String toolName, JsonNode arguments) {
}

View File

@@ -0,0 +1,108 @@
package io.shinhanlife.dap.biz.mcp.execute;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
import org.springframework.stereotype.Service;
import tools.jackson.databind.JsonNode;
/**
* MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry,
* argument validator, routing service, {@link ToolClient}와 Tool HTTP 호출·응답 경계를 기록하는 trace logger입니다.
*/
@Service
public class ToolExecutionService {
private final ToolRegistryService registryService;
private final ToolArgumentValidator argumentValidator;
private final ToolRoutingService routingService;
private final ToolClient toolClient;
private final TraceLogger traceLogger;
/**
* metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다.
*/
public ToolExecutionService(
ToolRegistryService registryService,
ToolArgumentValidator argumentValidator,
ToolRoutingService routingService,
ToolClient toolClient,
TraceLogger traceLogger) {
this.registryService = registryService;
this.argumentValidator = argumentValidator;
this.routingService = routingService;
this.toolClient = toolClient;
this.traceLogger = traceLogger;
}
/**
* Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 한 번 실행합니다. 처리 순서는 Registry 조회 → inputSchema 검증 → endpoint/timeout 확정 → ToolClient 호출이며, 호출 전후에는
* payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다.
*/
public Result execute(ToolCall call, McpRequestContext context) {
ToolMetadata metadata = registryService.findEnabledTool(call.toolName());
argumentValidator.validate(call, metadata);
ToolRequest toolRequest = routingService.route(call, metadata);
traceLogger.event(
"tool_http_request_started",
"toolName",
toolRequest.toolName(),
"version",
toolRequest.version());
long started = System.nanoTime();
try {
ToolResponse response = toolClient.execute(toolRequest, context);
double duration = elapsedMillis(started);
traceLogger.event(
"tool_http_response_received",
"toolName",
toolRequest.toolName(),
"statusCode",
response.statusCode(),
"durationMillis",
duration);
return new Result(response.data(), duration);
} catch (ToolClientException exception) {
traceLogger.error("tool_http_request_failed", exception, "toolName", toolRequest.toolName());
throw mapException(exception, toolRequest);
}
}
/**
* System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다.
*/
private double elapsedMillis(long startedNanos) {
return (System.nanoTime() - startedNanos) / 1_000_000.0d;
}
/**
* Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다.
*/
private JsonRpcException mapException(ToolClientException exception, ToolRequest request) {
JsonRpcErrorCode code =
switch (exception.kind()) {
case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT;
case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED;
case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN;
case EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR;
};
return new JsonRpcException(
code,
request.toolName() + "@" + request.version() + ": " + exception.getMessage(),
exception);
}
/**
* Tool Service가 반환한 정규화된 본문과 MCP가 측정한 실행 시간을 handler에 전달하는 불변 결과입니다. {@link io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler}가 이를 MCP text content와
* {@code searchTime}으로 변환합니다.
*/
public record Result(JsonNode data, double durationMillis) {
}
}

View File

@@ -0,0 +1,68 @@
package io.shinhanlife.dap.biz.mcp.execute;
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.ToolMetadata;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
import java.net.URI;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* Registry에서 확정된 Tool metadata를 실제 {@link ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. 확정된 Tool metadata에 대해서만 동작하며, AgentBuilder 대신 Tool을 선택하거나 업무 규칙을 판단하지
* 않습니다. 주요 의존성은 {@link ToolCall}, {@link ToolMetadata}와 {@link io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다.
*/
@Service
public class ToolRoutingService {
private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}");
private final McpProperties properties;
/**
* Tool별 timeout이 없을 때 사용할 공통 Tool client 설정을 주입받습니다.
*/
public ToolRoutingService(McpProperties properties) {
this.properties = properties;
}
/**
* 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. 절대 HTTP(S) endpoint와 Tool 이름을 검증하고 {@code POST {baseEndpoint}/{toolName}} 주소를 확정합니다.
*/
public ToolRequest route(ToolCall call, ToolMetadata metadata) {
String endpoint = metadata.endpoint();
validateEndpoint(endpoint, metadata.name());
if (metadata.name() == null || !TOOL_NAME.matcher(metadata.name()).matches()) {
throw new JsonRpcException(
JsonRpcErrorCode.INVALID_PARAMS,
"params.name must contain 1-64 letters, digits, underscore, hyphen, dot, or slash");
}
endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name();
return new ToolRequest(
metadata.name(),
metadata.version(),
endpoint,
call.arguments().deepCopy(),
metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis()));
}
/**
* Tool endpoint가 절대 HTTP(S) URL인지 검사해 내부망 상대 경로나 다른 scheme 호출을 막습니다.
*/
private void validateEndpoint(String endpoint, String toolName) {
try {
URI uri = URI.create(endpoint);
if (!uri.isAbsolute()
|| !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
throw new IllegalArgumentException("endpoint must be absolute HTTP(S)");
}
} catch (RuntimeException exception) {
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_EXECUTION_ERROR,
"Invalid Tool endpoint for " + toolName,
exception);
}
}
}

View File

@@ -0,0 +1,46 @@
package io.shinhanlife.dap.biz.mcp.jsonrpc;
import io.modelcontextprotocol.spec.McpSchema;
/**
* 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된
* 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. 표준 JSON-RPC 숫자는 MCP SDK 상수를 사용하고, Tool 실행·Registry·권한 오류만 이 서버의 확장 범위로 유지합니다.
*/
public enum JsonRpcErrorCode {
PARSE_ERROR(McpSchema.ErrorCodes.PARSE_ERROR, "Parse error"),
INVALID_REQUEST(McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid Request"),
METHOD_NOT_FOUND(McpSchema.ErrorCodes.METHOD_NOT_FOUND, "Method not found"),
INVALID_PARAMS(McpSchema.ErrorCodes.INVALID_PARAMS, "Invalid params"),
INTERNAL_ERROR(McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal error"),
TOOL_EXECUTION_ERROR(-32000, "Tool execution error"),
TOOL_NOT_FOUND(-32001, "Tool not found"),
TOOL_TIMEOUT(-32002, "Tool timeout"),
TOOL_REGISTRY_UNAVAILABLE(-32003, "Tool registry unavailable"),
UNAUTHORIZED(-32004, "Unauthorized"),
FORBIDDEN(-32005, "Forbidden");
private final int code;
private final String message;
/**
* 숫자 오류 코드와 외부에 표시할 표준 메시지를 한 쌍으로 저장합니다.
*/
JsonRpcErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
/**
* JSON-RPC error 객체에 기록할 숫자 코드를 반환합니다.
*/
public int code() {
return code;
}
/**
* JSON-RPC error 객체에 기록할 안전한 기본 메시지를 반환합니다.
*/
public String message() {
return message;
}
}

View File

@@ -0,0 +1,60 @@
package io.shinhanlife.dap.biz.mcp.jsonrpc;
import tools.jackson.databind.JsonNode;
/**
* 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. transport, registry, execute 계층이 이 예외를 발생시키고, {@code McpController} 또는
* {@code McpExceptionHandler}가 JSON-RPC error 응답으로 변환합니다. 주요 의존성은 {@link JsonRpcErrorCode}와 응답 correlation을 위한 JSON 요청 ID이며, HTTP 응답을 직접 만들지 않습니다.
*/
public class JsonRpcException extends RuntimeException {
private final JsonRpcErrorCode errorCode;
private final Object errorData;
private final JsonNode requestId;
/**
* 오류 코드와 간단한 상세 설명만으로 JSON-RPC 예외를 만듭니다.
*/
public JsonRpcException(JsonRpcErrorCode errorCode, String details) {
this(errorCode, details, null, null);
}
/**
* 원인 예외를 함께 보존해야 할 때 사용하는 생성자입니다.
*/
public JsonRpcException(JsonRpcErrorCode errorCode, String details, Throwable cause) {
this(errorCode, details, null, cause);
}
/**
* 오류 코드, 응답 data, 원 요청 ID, 원인 예외를 모두 지정합니다. requestId를 보존하면 실패 응답도 어떤 JSON-RPC 요청에서 발생했는지 연결할 수 있습니다.
*/
public JsonRpcException(
JsonRpcErrorCode errorCode, Object errorData, JsonNode requestId, Throwable cause) {
super(errorData == null ? errorCode.message() : String.valueOf(errorData), cause);
this.errorCode = errorCode;
this.errorData = errorData;
this.requestId = requestId;
}
/**
* 표준 JSON-RPC 오류 종류를 반환합니다.
*/
public JsonRpcErrorCode errorCode() {
return errorCode;
}
/**
* 오류 응답의 data 영역에 넣을 안전한 상세 정보를 반환합니다.
*/
public Object errorData() {
return errorData;
}
/**
* 실패한 원 요청의 JSON-RPC id를 반환합니다.
*/
public JsonNode requestId() {
return requestId;
}
}

View File

@@ -0,0 +1,17 @@
package io.shinhanlife.dap.biz.mcp.jsonrpc;
import tools.jackson.databind.JsonNode;
/**
* 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증
* 정보는 포함하지 않고 request context가 별도로 관리합니다.
*/
public record JsonRpcRequest(String method, JsonNode params, JsonNode id) {
/**
* id가 없는 요청인지 확인하여 JSON-RPC notification 여부를 판단합니다.
*/
public boolean notification() {
return id == null || id.isNull();
}
}

View File

@@ -0,0 +1,65 @@
package io.shinhanlife.dap.biz.mcp.jsonrpc;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.JsonNodeFactory;
/**
* HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. 설정된 MCP POST endpoint의 모든 요청이 이 클래스를 지나며 여기서 JSON 구조를 검사합니다. HTTP 경계 로그는 filter가
* 담당하므로 이 parser는 별도 trace logger를 사용하지 않습니다.
*/
@Component
public class JsonRpcRequestParser {
/**
* HTTP 본문에서 읽은 JSON 객체를 서버 내부의 {@link JsonRpcRequest}로 변환합니다. envelope를 먼저 검증하며 params가 없으면 비어 있는 JSON 객체를 사용합니다. 지원 method 여부는 handler registry가 확인합니다.
*/
public JsonRpcRequest parse(JsonNode envelope) {
try {
validate(envelope);
String method = envelope.get("method").asString();
JsonNode params =
envelope.hasNonNull("params")
? envelope.get("params")
: JsonNodeFactory.instance.objectNode();
return new JsonRpcRequest(method, params, envelope.get("id"));
} catch (JsonRpcException exception) {
JsonNode id = envelope != null && envelope.isObject() ? envelope.get("id") : null;
throw new JsonRpcException(exception.errorCode(), exception.errorData(), id, exception);
}
}
/**
* JSON-RPC 2.0 요청 envelope의 필수 구조와 타입을 검사합니다.
*/
private void validate(JsonNode envelope) {
if (envelope == null || !envelope.isObject()) {
throw new JsonRpcException(
JsonRpcErrorCode.INVALID_REQUEST, "JSON-RPC envelope must be an object");
}
if (!McpSchema.JSONRPC_VERSION.equals(envelope.path("jsonrpc").asString(null))) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "jsonrpc must be exactly '2.0'");
}
String method = envelope.path("method").asString(null);
if (!StringUtils.hasText(method)) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "method is required");
}
boolean notification = method.startsWith("notifications/");
if (!notification && (!envelope.has("id") || envelope.get("id").isNull())) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "id is required for requests");
}
if (envelope.has("id")
&& !envelope.get("id").isNull()
&& !envelope.get("id").isString()
&& !envelope.get("id").isNumber()) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "id must be a string or number");
}
if (envelope.has("params")
&& !envelope.get("params").isNull()
&& !envelope.get("params").isObject()) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, "params must be an object");
}
}
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.dap.biz.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
import java.util.LinkedHashMap;
import java.util.Map;
import tools.jackson.databind.JsonNode;
/**
* MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. {@code McpController}와 {@code McpExceptionHandler}가 설정된 MCP endpoint의 응답 본문으로 사용하며, 성공과
* 오류를 동시에 넣지 않습니다. 주요 의존성은 request ID correlation을 위한 {@link JsonNode}와 null 필드를 제외하는 Jackson 직렬화 설정입니다.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNode id) {
/**
* 정상 처리 결과와 원 요청 ID를 JSON-RPC 2.0 성공 응답으로 감쌉니다.
*/
public static JsonRpcResponse success(JsonNode id, Object result) {
return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, result, null, id);
}
/**
* 표준 오류 정보와 원 요청 ID를 JSON-RPC 2.0 실패 응답으로 감쌉니다.
*/
public static JsonRpcResponse failure(JsonNode id, Error error) {
return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, null, error, id);
}
/**
* 내부 오류 코드와 상세 정보를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다.
*/
public static JsonRpcResponse failure(JsonNode id, JsonRpcErrorCode code, Object details) {
Map<String, Object> data = new LinkedHashMap<>();
McpRequestContextHolder.get()
.map(context -> context.guid())
.ifPresent(guid -> data.put("guid", guid));
if (details != null) {
data.put("details", details);
}
String message =
code == JsonRpcErrorCode.INVALID_PARAMS && details != null
? code.message() + ": " + details
: code.message();
return failure(id, new Error(code.code(), message, data));
}
/**
* JSON-RPC 오류의 code·message·선택 data를 담는 하위 값 객체입니다. {@link JsonRpcResponse#failure(JsonNode, JsonRpcErrorCode, Object)}가 생성하며, Tool 업무 실패 결과에는 사용하지 않습니다.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Error(int code, String message, Object data) {
}
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.dap.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import org.springframework.stereotype.Component;
/**
* MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며,
* 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다. 주요 의존성은 서버명·버전·프로토콜 설정을 제공하는 {@link McpProperties}, MCP SDK 표준 초기화 모델, JSON-RPC 응답 envelope입니다.
*/
@Component
public class InitializeHandler implements McpMethodHandlerRegistry.Handler {
private final McpProperties properties;
/**
* initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다.
*/
public InitializeHandler(McpProperties properties) {
this.properties = properties;
}
/**
* 이 handler가 담당하는 MCP method 이름인 `initialize`를 반환합니다.
*/
@Override
public String method() {
return McpSchema.METHOD_INITIALIZE;
}
/**
* Agent Builder에 protocol version, 서버 정보, 지원 capability를 알려 주는 initialize 결과를 만듭니다. 요청 ID를 그대로 응답에 넣어 JSON-RPC correlation을 유지합니다.
*/
@Override
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
McpSchema.Implementation serverInfo =
McpSchema.Implementation.builder(properties.server().name(), properties.server().version())
.title(properties.server().title())
.build();
McpSchema.ServerCapabilities capabilities =
McpSchema.ServerCapabilities.builder().tools(false).build();
McpSchema.InitializeResult result =
McpSchema.InitializeResult.builder(
properties.protocol().preferredVersion(), capabilities, serverInfo)
.build();
return JsonRpcResponse.success(request.id(), result);
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. 이 요청은 서버 상태나 세션을 만들지 않고 {@code McpController}가 HTTP 202으로
* 마무리합니다. 별도 협력 객체 없이 표준 notification acknowledgement만 반환합니다.
*/
@Component
public class InitializedNotificationHandler implements McpMethodHandlerRegistry.Handler {
/**
* 이 handler가 담당하는 `notifications/initialized` method 이름을 반환합니다.
*/
@Override
public String method() {
return McpSchema.METHOD_NOTIFICATION_INITIALIZED;
}
/**
* Agent Builder의 initialize 완료 notification을 수용합니다. 서버 상태를 생성하지 않으며 {@code McpController}가 HTTP 202 빈 응답으로 최종 처리합니다.
*/
@Override
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
return JsonRpcResponse.success(null, Map.of());
}
}

View File

@@ -0,0 +1,65 @@
package io.shinhanlife.dap.biz.mcp.method;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Spring이 만든 MCP method handler를 method 문자열 기준으로 인덱싱하고, {@code McpController}의 명시적 dispatch를 지원합니다. 설정된 MCP endpoint 요청은 transport 검증 후 이 registry에서
* initialize·tools/list·tools/call handler를 찾아 처리합니다. 주요 의존성은 {@link Handler} 구현체 목록과 지원하지 않는 method를 거절하는 JSON-RPC 오류 모델입니다.
*/
@Component
public class McpMethodHandlerRegistry {
private final Map<String, Handler> handlers;
/**
* Spring이 찾은 모든 handler를 method 이름 기준의 읽기 전용 map으로 구성합니다. 같은 method를 담당하는 handler가 둘이면 시작 시 즉시 실패해 모호한 dispatch를 막습니다.
*/
public McpMethodHandlerRegistry(List<Handler> handlers) {
Map<String, Handler> indexed = new HashMap<>();
handlers.forEach(
handler -> {
if (indexed.putIfAbsent(handler.method(), handler) != null) {
throw new IllegalStateException("Duplicate MCP method handler: " + handler.method());
}
});
this.handlers = Map.copyOf(indexed);
}
/**
* 요청 method에 맞는 handler를 반환하고, 지원하지 않으면 Method not found 오류를 발생시킵니다.
*/
public Handler resolve(String method) {
Handler handler = handlers.get(method);
if (handler == null) {
throw new JsonRpcException(
JsonRpcErrorCode.METHOD_NOT_FOUND, "Unsupported MCP method: " + method);
}
return handler;
}
/**
* MCP method별 처리기를 위한 내부 확장 계약입니다. {@code McpController}는 이 계약만 의존하므로 새 method는 이 인터페이스 구현체를 Bean으로 추가해 등록할 수 있습니다.
*/
public interface Handler {
/**
* 이 handler가 처리할 JSON-RPC method 문자열을 반환합니다.
*/
String method();
/**
* 검증된 요청과 request context를 받아 method별 JSON-RPC 응답을 생성합니다.
*/
JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context);
}
}

View File

@@ -0,0 +1,120 @@
package io.shinhanlife.dap.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
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 java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tools.jackson.databind.JsonNode;
/**
* MCP {@code tools/call} 요청을 받아 Tool 실행 계층으로 전달하고 MCP result 형식으로 되돌리는 method handler입니다. {@link ToolExecutionService}를 통해 Tool을 실행하고 결과는 MCP SDK의
* {@link McpSchema.CallToolResult}로 만듭니다. 주요 의존성은 실행 서비스이며, 잘못된 요청은 최상위 JSON-RPC error로, Tool 자체 실패는 {@code result.isError=true}로 구분합니다.
*/
@Component
public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler {
private final ToolExecutionService executionService;
/**
* Tool 실행 서비스를 주입받습니다.
*/
public ToolsCallHandler(ToolExecutionService executionService) {
this.executionService = executionService;
}
/**
* 이 handler가 담당하는 `tools/call` method 이름을 반환합니다.
*/
@Override
public String method() {
return McpSchema.METHOD_TOOLS_CALL;
}
/**
* 일반 tools/call 요청에서 명시된 단일 Tool을 실행합니다. Tool 실행 계열 오류는 MCP 규칙에 맞춰 최상위 error가 아닌 `isError=true` result로 변환합니다.
*/
@Override
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
ToolCall call = extract(request);
try {
ToolExecutionService.Result result = executionService.execute(call, context);
return JsonRpcResponse.success(request.id(), successResult(result));
} catch (JsonRpcException exception) {
if (isToolExecutionFailure(exception.errorCode())) {
return JsonRpcResponse.success(request.id(), failureResult(exception.errorData()));
}
throw exception;
}
}
/**
* 표준 tools/call params에서 Tool 이름과 object arguments를 검증해 내부 호출 값으로 만듭니다.
*/
private ToolCall extract(JsonRpcRequest request) {
String toolName = request.params().path("name").asString(null);
JsonNode arguments = request.params().get("arguments");
if (!StringUtils.hasText(toolName)) {
throw invalid(request, "params.name is required");
}
if (arguments == null || !arguments.isObject()) {
throw invalid(request, "params.arguments must be an object");
}
return new ToolCall(toolName, arguments);
}
/**
* 요청 ID를 보존한 Invalid params 예외를 만듭니다.
*/
private JsonRpcException invalid(JsonRpcRequest request, String details) {
return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details, request.id(), null);
}
/**
* Tool 실행 결과를 text content와 실행 시간 meta를 가진 MCP 성공 결과로 변환합니다.
*/
private McpSchema.CallToolResult successResult(ToolExecutionService.Result result) {
McpSchema.TextContent content =
McpSchema.TextContent.builder(asText(result.data()))
.meta(Map.of("searchTime", result.durationMillis()))
.build();
return McpSchema.CallToolResult.builder(List.of(content)).isError(false).build();
}
/**
* Tool 응답을 MCP text content에 넣을 문자열로 바꾸며 JSON 객체와 배열은 compact JSON을 유지합니다.
*/
private String asText(JsonNode data) {
if (data == null || data.isNull()) {
return "";
}
return data.isString() ? data.asString() : data.toString();
}
/**
* Tool 실패 상세를 사용자에게 전달 가능한 text content와 `isError=true` 결과로 변환합니다.
*/
private McpSchema.CallToolResult failureResult(Object details) {
McpSchema.TextContent content = McpSchema.TextContent.builder(String.valueOf(details)).build();
return McpSchema.CallToolResult.builder(List.of(content)).isError(true).build();
}
/**
* JSON-RPC envelope 오류가 아니라 MCP Tool result로 표현해야 하는 실행 계열 오류인지 구분합니다.
*/
private boolean isToolExecutionFailure(JsonRpcErrorCode errorCode) {
return errorCode == JsonRpcErrorCode.TOOL_EXECUTION_ERROR
|| errorCode == JsonRpcErrorCode.TOOL_TIMEOUT
|| errorCode == JsonRpcErrorCode.UNAUTHORIZED
|| errorCode == JsonRpcErrorCode.FORBIDDEN;
}
}

View File

@@ -0,0 +1,89 @@
package io.shinhanlife.dap.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
import java.util.List;
import org.springframework.stereotype.Component;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. 내부 Tool Registry의 활성 metadata를 읽어 MCP SDK의 표준 {@link McpSchema.Tool}과
* {@link McpSchema.ListToolsResult}로 변환합니다. 주요 의존성은 캐시 및 원천 조회를 감싸는 {@link io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local
* catalog의 공개 필드만 SDK 모델로 옮깁니다. endpoint·timeout 등 실행용 운영 정보는 응답에 노출하지 않습니다.
*/
@Component
public class ToolsListHandler implements McpMethodHandlerRegistry.Handler {
private final ToolRegistryService registryService;
private final ObjectMapper objectMapper;
/**
* 활성 Tool metadata를 조회할 Registry service와 SDK 모델 변환용 Jackson mapper를 주입받습니다.
*/
public ToolsListHandler(ToolRegistryService registryService, ObjectMapper objectMapper) {
this.registryService = registryService;
this.objectMapper = objectMapper;
}
/**
* 이 handler가 담당하는 `tools/list` method 이름을 반환합니다.
*/
@Override
public String method() {
return McpSchema.METHOD_TOOLS_LIST;
}
/**
* 실행용 metadata에서 외부 공개 필드만 골라 MCP tools/list 응답을 만듭니다. 내부 endpoint나 timeout 정보는 Agent Builder 응답에 노출하지 않습니다.
*/
@Override
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
List<McpSchema.Tool> tools = registryService.listTools().stream().map(this::toMcpTool).toList();
return JsonRpcResponse.success(request.id(), McpSchema.ListToolsResult.builder(tools).build());
}
/**
* 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은
* {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다.
*/
private McpSchema.Tool toMcpTool(ToolMetadata metadata) {
if (metadata.publicDefinition() != null) {
return objectMapper.convertValue(
withoutExecutionMetadata(metadata.publicDefinition()), McpSchema.Tool.class);
}
return McpSchema.Tool.builder(metadata.name(), toInputSchema(metadata))
.description(metadata.description())
.build();
}
/**
* 공개 Tool 정의를 복사해 실행용 {@code _meta}만 제거합니다. 원본 snapshot은 바꾸지 않습니다.
*/
private JsonNode withoutExecutionMetadata(JsonNode definition) {
if (!definition.isObject() || !definition.has("_meta")) {
return definition;
}
ObjectNode copy = ((ObjectNode) definition).deepCopy();
copy.remove("_meta");
return copy;
}
/**
* 기존 Registry 응답에 inputSchema가 없으면 SDK 필수 조건을 만족하는 빈 object schema로 정규화합니다. schema가 있으면 field를 변경하지 않고 Jackson Map으로 옮깁니다.
*/
@SuppressWarnings("unchecked")
private java.util.Map<String, Object> toInputSchema(ToolMetadata metadata) {
if (metadata.inputSchema() == null || metadata.inputSchema().isNull()) {
return java.util.Map.of("type", "object", "properties", java.util.Map.of());
}
return objectMapper.convertValue(metadata.inputSchema(), java.util.Map.class);
}
}

View File

@@ -0,0 +1,41 @@
package io.shinhanlife.dap.biz.mcp.observability;
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
import java.util.List;
import java.util.Map;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* 설정에 선언된 Tool Service bundle의 조회 상태를 management endpoint로 제공하는 운영 진단 구성요소입니다. MCP JSON-RPC 요청을 처리하지 않으며 Actuator가 {@code toolBundles} read operation을 호출합니다.
* 주요 의존성은 bundle별 last-good과 실패 상태를 보관하는 {@link ToolBundleDiscovery}이며, manifest URL이나 Tool schema 같은 내부 상세 정보는 응답에 포함하지 않습니다.
*/
@Component
@Endpoint(id = "toolBundles")
@Profile("!local")
@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true")
public class ToolBundleStatusEndpoint {
private final ToolBundleDiscovery discovery;
/**
* 운영 조회 시 사용할 bundle discovery 상태 저장소를 주입받습니다.
*/
public ToolBundleStatusEndpoint(ToolBundleDiscovery discovery) {
this.discovery = discovery;
}
/**
* 선언된 모든 bundle의 현재 상태를 읽기 전용 Map으로 반환합니다. 상태 조회는 manifest refresh나 Tool 실행을 유발하지 않습니다.
*/
@ReadOperation
public Map<String, List<BundleStatus>> bundleStatuses() {
return Map.of("bundles", discovery.statuses());
}
}

View File

@@ -0,0 +1,43 @@
package io.shinhanlife.dap.biz.mcp.observability;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
/**
* Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. MCP 요청을 직접 처리하지 않으며 {@code /actuator/health/readiness}의 readiness group에서
* 평가됩니다. 원천 조회가 실패해도 memory 또는 Redis의 last-good snapshot이 있으면 서비스 가능 상태로 인정하지만, 사용할 snapshot이 전혀 없는 Pod은 트래픽을 받지 않습니다. 주요 협력 객체는 기동 조회 완료 시점을 제공하는
* {@link ToolRegistryRefreshScheduler}와 요청 경로의 snapshot을 소유하는 {@link ToolRegistryService}입니다.
*/
@Component
public class ToolCatalogHealthIndicator implements HealthIndicator {
private final ToolRegistryRefreshScheduler scheduler;
private final ToolRegistryService registryService;
/**
* 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다.
*/
public ToolCatalogHealthIndicator(
ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) {
this.scheduler = scheduler;
this.registryService = registryService;
}
/**
* 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다.
*/
@Override
public Health health() {
boolean firstAttemptCompleted = scheduler.firstAttemptCompleted();
boolean usableSnapshot = registryService.hasUsableSnapshot();
Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down();
return health.withDetail(
"firstDiscoveryAttempt",
firstAttemptCompleted ? "completed" : "pending")
.withDetail("usableSnapshot", usableSnapshot)
.build();
}
}

View File

@@ -0,0 +1,95 @@
package io.shinhanlife.dap.biz.mcp.observability;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
import java.util.StringJoiner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다. 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다.
* payload, credential, 사원 식별자({@code employeeNo}·{@code virtualEmployeeNo})는 기록하지 않습니다. 주요 의존성은 로그 활성화 정책을 제공하는 {@link McpProperties}와 SLF4J입니다.
*/
@Component
public class TraceLogger {
private static final Logger log = LoggerFactory.getLogger(TraceLogger.class);
private final McpProperties properties;
/**
* trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다.
*/
public TraceLogger(McpProperties properties) {
this.properties = properties;
}
/**
* 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. trace 로그 설정이 켜진 경우에만 기록합니다.
*/
public void event(String event, Object... keyValues) {
if (properties.trace().enabled()) {
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
log.info(
"event={} guid={} requestId={} {}",
safe(event),
guid(context),
requestId(context),
fields(keyValues));
}
}
/**
* 예외가 발생한 처리 단계를 오류 로그로 남깁니다. 오류 로그에는 예외 종류와 메시지를 함께 남겨 원인 분석을 돕습니다.
*/
public void error(String event, Throwable error, Object... keyValues) {
McpRequestContext context = McpRequestContextHolder.get().orElse(null);
log.error(
"event={} guid={} requestId={} {} errorType={} errorMessage={}",
safe(event),
guid(context),
requestId(context),
fields(keyValues),
error.getClass().getSimpleName(),
safe(error.getMessage()),
error);
}
/**
* 가변 인자로 받은 키와 값을 두 개씩 묶어 읽기 쉬운 key=value 문자열로 바꿉니다. 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다.
*/
private String fields(Object... keyValues) {
StringJoiner joiner = new StringJoiner(" ");
for (int index = 0; index + 1 < keyValues.length; index += 2) {
joiner.add(safe(keyValues[index]) + "=" + safe(keyValues[index + 1]));
}
return joiner.toString();
}
/**
* 줄바꿈과 공백을 치환해 한 로그 이벤트가 여러 줄로 갈라지지 않도록 문자열을 정리합니다.
*/
private String safe(Object value) {
if (value == null) {
return "";
}
return String.valueOf(value).replace('\n', '_').replace('\r', '_').replace(' ', '_');
}
/**
* 요청 context가 있을 때 end-to-end 상관 값 guid를 반환하고, background 로그에는 빈 값을 사용합니다.
*/
private String guid(McpRequestContext context) {
return context == null ? "" : safe(context.guid());
}
/**
* 요청 context가 있을 때 개별 HTTP request ID를 반환하고, background 로그에는 빈 값을 사용합니다.
*/
private String requestId(McpRequestContext context) {
return context == null ? "" : safe(context.requestId());
}
}

View File

@@ -0,0 +1,135 @@
package io.shinhanlife.dap.biz.mcp.registry;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. {@code tools/list}와 local
* {@code tools/call}이 metadata를 필요로 할 때 {@link ToolRegistryService}가 cache miss 후 호출합니다. 주요 의존성은 설정의 local 파일 경로를 제공하는 McpProperties와 JSON 파싱용 ObjectMapper이며,
* 운영 HTTP Registry를 호출하지 않습니다.
*/
@Component
@Profile("local")
@ConditionalOnProperty(
prefix = "mcp.discovery",
name = "enabled",
havingValue = "false",
matchIfMissing = true)
public class LocalFileToolRegistryClient implements ToolRegistryClient {
private final ResourceLoader resourceLoader;
private final ObjectMapper objectMapper;
private final McpProperties properties;
/**
* local Tool catalog의 resource loader, JSON mapper, 파일 위치 설정을 주입받습니다.
*/
public LocalFileToolRegistryClient(
ResourceLoader resourceLoader, ObjectMapper objectMapper, McpProperties properties) {
this.resourceLoader = resourceLoader;
this.objectMapper = objectMapper;
this.properties = properties;
}
/**
* local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다.
*/
@Override
public List<ToolMetadata> fetchTools() {
String location = properties.registry().localToolFile();
Resource resource = resourceLoader.getResource(location);
try (var inputStream = resource.getInputStream()) {
JsonNode document = objectMapper.readTree(inputStream);
return toToolMetadataList(document, location);
} catch (JsonRpcException exception) {
throw exception;
} catch (IOException exception) {
throw unavailable(location, "Unable to read local Tool catalog", exception);
}
}
/**
* Agent Builder tools/list 응답 또는 Tool Service manifest의 공개 정의와 {@code _meta} 실행 정보를 내부 ToolMetadata로 조합합니다. 두 형식 모두 배열이 없으면 오류로 처리해 빈 목록을 조용히 반환하지 않습니다.
*/
private List<ToolMetadata> toToolMetadataList(JsonNode document, String location) {
JsonNode tools = document.path("result").path("tools");
if (!tools.isArray()) {
tools = document.path("tools");
}
if (!tools.isArray()) {
throw unavailable(location, "Local Tool catalog must contain tools array", null);
}
List<ToolMetadata> metadata = new ArrayList<>();
for (JsonNode tool : tools) {
metadata.add(toToolMetadata(tool, location));
}
return List.copyOf(metadata);
}
/**
* 한 공개 Tool 정의에서 name·description·inputSchema와 {@code _meta}의 endpoint·timeout·상태를 추출합니다. 실행에 필수인 name 또는 endpoint가 없으면 local 설정 오류로 처리해 잘못된 Tool 호출을
* 막습니다.
*/
private ToolMetadata toToolMetadata(JsonNode tool, String location) {
JsonNode meta = tool.path("_meta");
String name = requiredText(tool, "name", location);
String endpoint = requiredText(meta, "endpoint", location);
String version = meta.path("version").asString("local");
int timeoutMillis =
meta.path("timeoutMillis").asInt(properties.toolClient().readTimeoutMillis());
boolean enabled = meta.path("enabled").asBoolean(true);
return new ToolMetadata(
name,
version,
tool.path("description").asString(""),
endpoint,
tool.get("inputSchema"),
timeoutMillis,
enabled,
publicDefinition(tool));
}
/**
* local 파일의 공개 Tool 정의를 복사하고 내부 실행 metadata인 {@code _meta}만 제거합니다.
*/
private JsonNode publicDefinition(JsonNode tool) {
ObjectNode definition = ((ObjectNode) tool).deepCopy();
definition.remove("_meta");
return definition;
}
/**
* local sample의 필수 문자열 field를 검증하고 누락 시 Registry unavailable 오류로 바꿉니다.
*/
private String requiredText(JsonNode source, String fieldName, String location) {
String value = source.path(fieldName).asString(null);
if (value == null || value.isBlank()) {
throw unavailable(location, "Local Tool catalog is missing " + fieldName, null);
}
return value;
}
/**
* 파일 위치와 실패 이유를 포함하되 원문 payload는 노출하지 않는 Registry 오류를 만듭니다.
*/
private JsonRpcException unavailable(String location, String message, Exception cause) {
String details = message + ": " + location;
return cause == null
? new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, details)
: new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, details, cause);
}
}

View File

@@ -0,0 +1,92 @@
package io.shinhanlife.dap.biz.mcp.registry;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* MCP replica 사이에서 Tool snapshot을 공유하는 선택적 Redis cache adapter입니다. 원천이 아니라 <b>공유 지점</b>이므로 조회 성공 결과만 저장하고, 읽기·쓰기·직렬화 실패는 모두 cache miss로 처리합니다.
* {@code tools/list} 요청 경로에서는 호출하지 않으며 {@link ToolRegistryService}의 배경 갱신과 warm start에서만 사용합니다. 주요 의존성은 RedisTemplate, ObjectMapper와 {@link McpProperties}입니다.
*/
@Component
@ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true")
public class RedisToolRegistryCache {
/**
* 캐시에 저장하는 JSON 구조의 버전입니다. 구조가 바뀌면 이 값을 올려 서로 다른 버전의 MCP가 같은 key를 읽어 오염되는 것을 막습니다.
*/
static final String CACHE_SCHEMA_VERSION = "v1";
private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class);
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final String cacheKey;
private final Duration ttl;
/**
* Redis 접근, JSON 변환, key와 TTL 설정을 주입받아 공유 cache를 구성합니다.
*/
public RedisToolRegistryCache(
StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.cacheKey =
"%s:%s:%s:all"
.formatted(properties.redis().keyPrefix(), properties.identity(), CACHE_SCHEMA_VERSION);
this.ttl = Duration.ofSeconds(Math.max(30, properties.registry().refreshIntervalSeconds() * 3));
}
/**
* 이 MCP 인스턴스가 사용하는 Redis key를 반환합니다. 운영 진단과 테스트에서 key 규칙을 확인할 때 사용합니다.
*/
public String key() {
return cacheKey;
}
/**
* 다른 replica가 저장한 Tool snapshot을 읽습니다. key miss, Redis 장애와 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 결과로 진행하게 합니다.
*/
public Optional<List<ToolMetadata>> loadSnapshot() {
try {
String json = redisTemplate.opsForValue().get(cacheKey);
if (json == null) {
return Optional.empty();
}
return Optional.of(objectMapper.readValue(json, new TypeReference<>() {
}));
} catch (Exception exception) {
logFailure("read", exception);
return Optional.empty();
}
}
/**
* 원천 조회에 성공한 snapshot만 공유 지점에 저장하고 TTL을 설정합니다. 실패한 조회 결과를 저장하면 다른 replica가 구해 온 정상 snapshot을 덮어쓰므로 호출자가 성공 시에만 호출해야 합니다. 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신을
* 실패시키지 않습니다.
*/
public void saveSnapshot(List<ToolMetadata> tools) {
try {
redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(tools), ttl);
} catch (Exception exception) {
logFailure("write", exception);
}
}
/**
* payload와 credential을 남기지 않고 Redis 실패 작업과 예외 타입만 기록합니다.
*/
private void logFailure(String operation, Exception exception) {
logger.warn("Redis Tool cache {} failed: {}", operation, exception.getClass().getSimpleName());
}
}

View File

@@ -0,0 +1,428 @@
package io.shinhanlife.dap.biz.mcp.registry;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* 설정에 선언된 Tool Service bundle의 매니페스트를 동시에 조회·검증하고 bundle별 상태를 보관하는 discovery 구성요소입니다. MCP 요청을 직접 처리하지 않으며 {@link ToolBundleRegistryClient}의 배경 갱신에서만 호출됩니다. 개별
* bundle의 실패는 예외가 아니라 결과값으로 반환해, 한 bundle의 장애가 나머지 bundle의 성공분까지 버리지 않게 합니다. 최초 원격 조회가 실패한 경우에만 설정된 local manifest를 cold-start fallback으로 사용합니다. 주요 의존성은 bundle
* 전용 RestClient, JSON mapper, ResourceLoader, {@link McpProperties}의 bundle·discovery 설정입니다.
*/
@Component
@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true")
public class ToolBundleDiscovery {
private static final Logger logger = LoggerFactory.getLogger(ToolBundleDiscovery.class);
private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}");
private final RestClient restClient;
private final ObjectMapper objectMapper;
private final McpProperties properties;
private final ResourceLoader resourceLoader;
private final Map<String, BundleState> states = new ConcurrentHashMap<>();
/**
* bundle 매니페스트 조회용 RestClient, JSON mapper, 조회 정책 설정을 주입받습니다. local fallback 파일은 Spring ResourceLoader로 읽어 file:과 classpath: 위치를 모두 지원합니다.
*/
public ToolBundleDiscovery(
@Qualifier("manifestRestClient") RestClient restClient,
ObjectMapper objectMapper,
McpProperties properties) {
this.restClient = restClient;
this.objectMapper = objectMapper;
this.properties = properties;
this.resourceLoader = new DefaultResourceLoader();
}
/**
* 활성 bundle 전체를 동시에 조회해 bundle별 결과를 반환합니다. 순차 조회는 소요 시간이 합산되어 기동과 갱신을 지연시키므로 virtual thread로 병렬 조회하며, 각 작업이 자기 예외를 결과값으로 변환하므로 이 method는 예외를 던지지 않습니다.
*/
public List<BundleResult> discoverAll() {
List<Bundle> targets = properties.enabledBundles();
if (targets.isEmpty()) {
return List.of();
}
List<Future<BundleResult>> futures;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
futures =
targets.stream()
.map(bundle -> executor.<BundleResult>submit(() -> discoverOne(bundle)))
.toList();
}
// close()가 모든 작업의 종료를 기다린 뒤이므로 이 시점의 Future는 모두 완료 상태다.
List<BundleResult> results = new ArrayList<>();
for (int index = 0; index < futures.size(); index++) {
results.add(resultOrEmpty(futures.get(index), targets.get(index)));
}
return List.copyOf(results);
}
/**
* 완료된 조회 작업의 결과를 꺼내되, 작업 자체가 비정상 종료했으면 빈 결과로 대체합니다. {@link #discoverOne}이 이미 모든 RuntimeException을 값으로 바꾸므로 이 경로는 예상 밖의 오류에 대한 마지막 방어선입니다.
*/
private BundleResult resultOrEmpty(Future<BundleResult> future, Bundle bundle) {
try {
return future.resultNow();
} catch (RuntimeException exception) {
logger.warn(
"Tool bundle discovery task ended abnormally: bundleId={}, reason={}",
bundle.id(),
exception.getClass().getSimpleName());
return new BundleResult(bundle.id(), false, List.of());
}
}
/**
* bundle 하나의 매니페스트를 조회·검증하고 그 결과로 bundle 상태를 갱신합니다. 성공하면 새 Tool 목록을 채택하고 실패 횟수를 초기화하며, 실패하면 직전 성공본을 유지한 채 실패 횟수만 올립니다. 통신 실패만으로 Tool을 제거하지 않으며 정상
* manifest에서 제거가 확인될 때만 새 목록을 채택합니다.
*/
BundleResult discoverOne(Bundle bundle) {
BundleState state = states.computeIfAbsent(bundle.id(), id -> new BundleState());
try {
Manifest manifest = fetchAndValidate(bundle);
return state.recordSuccess(bundle.id(), manifest.tools(), manifest.revision());
} catch (RuntimeException exception) {
BundleResult fallback = loadColdStartFallback(bundle, state, exception);
if (fallback != null) {
return fallback;
}
// 실패를 값으로 돌려주는 지점이다. 여기서 예외를 올리면 다른 bundle의 성공분까지 함께 버려진다.
logger.warn(
"Tool bundle discovery failed: bundleId={}, reason={}",
bundle.id(),
exception.getClass().getSimpleName());
return state.recordFailure(bundle.id(), exception.getClass().getSimpleName());
}
}
/**
* 매니페스트를 HTTP로 읽어 크기 상한과 스키마 규칙을 검증한 뒤 실행 metadata 목록으로 변환합니다. 검증에 어긋나면 해당 Tool만 걸러내지 않고 bundle 전체를 거부합니다. 일부만 반영된 카탈로그는 잘못된 이름으로 조용히 실행되거나 필요한 Tool이 사라진
* 상태를 만들어, 직전 성공본을 유지하는 것보다 나쁘기 때문입니다.
*/
private Manifest fetchAndValidate(Bundle bundle) {
return parseAndValidate(bundle, fetchManifestBody(bundle));
}
/**
* 원격 매니페스트를 한 번도 받지 못한 bundle의 설정된 local manifest를 읽어 검증합니다. 정상 원격 snapshot이 있으면 호출하지 않으므로 테스트 파일이 운영 목록을 덮어쓰지 않습니다.
*/
private BundleResult loadColdStartFallback(
Bundle bundle, BundleState state, RuntimeException remoteFailure) {
String location = bundle.fallbackManifestFile();
if (state.hasSnapshot() || location == null || location.isBlank()) {
return null;
}
try {
Manifest manifest = parseAndValidate(bundle, fetchFallbackManifestBody(location));
logger.warn(
"Tool bundle discovery used local fallback: bundleId={}, reason={}",
bundle.id(),
remoteFailure.getClass().getSimpleName());
return state.recordFallback(
bundle.id(), manifest.tools(), manifest.revision(), remoteFailure.getClass().getSimpleName());
} catch (RuntimeException fallbackFailure) {
return null;
}
}
/**
* 원격 또는 local 원천에서 읽은 문자열을 동일한 bundle 규칙으로 검증합니다. 어느 원천이든 bundle ID·이름 접두사·schema 규칙이 다르면 전체 목록을 채택하지 않습니다.
*/
private Manifest parseAndValidate(Bundle bundle, String body) {
if (body == null || body.isBlank()) {
throw new IllegalStateException("empty manifest body");
}
JsonNode manifest = objectMapper.readTree(body);
String declaredId = manifest.path("bundleId").asString(null);
if (!bundle.id().equals(declaredId)) {
throw new IllegalStateException("manifest bundleId does not match configuration");
}
JsonNode tools = manifest.path("tools");
if (!tools.isArray()) {
throw new IllegalStateException("manifest must contain a tools array");
}
if (tools.size() > properties.discovery().maxToolsPerBundle()) {
throw new IllegalStateException("bundle exceeds maxToolsPerBundle");
}
Set<String> seenNames = new HashSet<>();
List<ToolMetadata> metadata = new ArrayList<>();
for (JsonNode tool : tools) {
ToolMetadata converted = toToolMetadata(bundle, tool);
if (!seenNames.add(converted.name())) {
throw new IllegalStateException("duplicate Tool name in manifest");
}
metadata.add(converted);
}
return new Manifest(manifest.path("revision").asString(null), List.copyOf(metadata));
}
/**
* 설정된 local fallback 파일을 크기 상한 안에서 읽습니다. 파일 경로나 본문은 로그에 남기지 않으며, 읽기 실패는 원격 실패를 가리는 대신 기존 unavailable 처리로 이어집니다.
*/
private String fetchFallbackManifestBody(String location) {
int maxBytes = properties.discovery().maxManifestBytes();
Resource resource = resourceLoader.getResource(location);
try (InputStream input = resource.getInputStream()) {
byte[] bytes = input.readNBytes(maxBytes + 1);
if (bytes.length > maxBytes) {
throw new IllegalStateException("fallback manifest exceeds " + maxBytes + " bytes");
}
return new String(bytes, StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("unable to read fallback manifest", exception);
}
}
/**
* manifest 응답을 설정 상한보다 한 byte만 더 읽어 초과 여부를 확인합니다. 전체 응답을 먼저 문자열로 적재하지 않으므로 잘못된 대용량 응답이 MCP heap을 불필요하게 소비하지 않습니다.
*/
private String fetchManifestBody(Bundle bundle) {
int maxBytes = properties.discovery().maxManifestBytes();
return restClient
.get()
.uri(bundle.manifestUrl())
.exchange(
(request, response) -> {
if (response.getStatusCode().isError()) {
throw new IllegalStateException(
"manifest returned HTTP " + response.getStatusCode().value());
}
try (InputStream input = response.getBody()) {
byte[] bytes = input.readNBytes(maxBytes + 1);
if (bytes.length > maxBytes) {
throw new IllegalStateException("manifest exceeds " + maxBytes + " bytes");
}
return new String(bytes, StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("unable to read manifest response", exception);
}
});
}
/**
* 한 번의 조회에서 검증을 통과한 매니페스트 내용입니다. {@code revision}은 변경 감지와 운영 진단에만 쓰는 보조 값입니다.
*/
private record Manifest(String revision, List<ToolMetadata> tools) {
}
/**
* 매니페스트의 Tool 하나를 실행 metadata로 변환하며, 실행 주소는 설정의 {@code baseEndpoint}에서만 가져옵니다. 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않으므로 Tool Service가 호출 대상을 바꿀 수 없습니다. 이름
* 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다.
*/
private ToolMetadata toToolMetadata(Bundle bundle, JsonNode tool) {
String name = tool.path("name").asString(null);
if (name == null || !TOOL_NAME.matcher(name).matches()) {
throw new IllegalStateException("Tool name must match [A-Za-z0-9_./-]{1,64}");
}
String prefix = bundle.namePrefix();
if (prefix != null && !prefix.isBlank() && !name.startsWith(prefix)) {
throw new IllegalStateException("Tool name does not start with the bundle namePrefix");
}
String description = tool.path("description").asString(null);
if (description == null || description.isBlank()) {
throw new IllegalStateException("Tool description is required");
}
JsonNode inputSchema = tool.get("inputSchema");
if (inputSchema == null || !inputSchema.isObject()) {
throw new IllegalStateException("Tool inputSchema must be a JSON Schema object");
}
JsonNode meta = tool.path("_meta");
String version = meta.path("version").asString(null);
if (version == null || version.isBlank()) {
throw new IllegalStateException("Tool _meta.version is required");
}
return new ToolMetadata(
name,
version,
description,
bundle.baseEndpoint().replaceAll("/+$", ""),
inputSchema,
clampTimeout(meta),
meta.path("enabled").asBoolean(true),
publicDefinition(tool));
}
/**
* Tool이 선언한 timeout을 설정 상한으로 절삭해, 한 Tool이 요청 예산 전체를 소모하지 못하게 합니다.
*/
private int clampTimeout(JsonNode meta) {
int max = properties.discovery().maxToolTimeoutMillis();
if (!meta.path("timeoutMillis").isNumber()) {
return Math.min(properties.toolClient().readTimeoutMillis(), max);
}
int declared = meta.path("timeoutMillis").intValue();
return declared <= 0
? Math.min(properties.toolClient().readTimeoutMillis(), max)
: Math.min(declared, max);
}
/**
* 매니페스트의 공개 Tool 정의를 복사하고 내부 실행 정보인 {@code _meta}만 제거해 tools/list 노출본을 만듭니다.
*/
private JsonNode publicDefinition(JsonNode tool) {
ObjectNode definition = ((ObjectNode) tool).deepCopy();
definition.remove("_meta");
return definition;
}
/**
* 설정에 선언된 모든 bundle의 현재 조회 상태를 반환합니다. 한 번도 조회에 성공하지 못한 bundle도 포함하므로, 설정의 기대값과 대조해 누락을 감지할 수 있습니다.
*/
public List<BundleStatus> statuses() {
return properties.bundles().stream()
.map(
bundle -> {
BundleState state = states.get(bundle.id());
return state == null ? BundleStatus.never(bundle) : state.toStatus(bundle);
})
.toList();
}
/**
* 한 bundle의 조회 결과이며, 노출할 Tool 목록과 사용 가능한 snapshot 여부를 함께 전달합니다.
*/
public record BundleResult(String bundleId, boolean usableSnapshot, List<ToolMetadata> tools) {
/**
* 노출할 Tool을 immutable copy로 고정합니다.
*/
public BundleResult {
tools = tools == null ? List.of() : List.copyOf(tools);
}
}
/**
* Actuator가 반환할 bundle 하나의 조회 상태 요약입니다. 원문 payload와 오류 메시지는 포함하지 않습니다.
*/
public record BundleStatus(
String bundleId,
boolean enabled,
String status,
String revision,
int toolCount,
int consecutiveFailures,
String lastSuccessAt,
String lastFailureReason) {
/**
* 설정에는 있으나 아직 한 번도 조회를 시도하지 않은 bundle의 상태를 만듭니다. 꺼 둔 bundle과 조회에 실패한 bundle은 원인이 다르므로 상태 문자열로 구분합니다.
*/
static BundleStatus never(Bundle bundle) {
String status = bundle.enabled() ? "unreachable" : "disabled";
return new BundleStatus(bundle.id(), bundle.enabled(), status, null, 0, 0, null, null);
}
}
/**
* bundle 하나의 마지막 성공 결과와 연속 실패 횟수를 보관하는 가변 상태입니다. 여러 조회 주기가 겹칠 수 있으므로 모든 갱신을 synchronized로 직렬화합니다.
*/
private static final class BundleState {
private List<ToolMetadata> lastGood;
private String revision;
private Instant lastSuccessAt;
private int consecutiveFailures;
private String lastFailureReason;
private boolean fallbackSnapshot;
/**
* 조회 성공 결과를 채택하고 실패 상태를 모두 초기화합니다.
*/
synchronized BundleResult recordSuccess(
String bundleId, List<ToolMetadata> tools, String revision) {
lastGood = tools;
this.revision = revision;
lastSuccessAt = Instant.now();
consecutiveFailures = 0;
lastFailureReason = null;
fallbackSnapshot = false;
return new BundleResult(bundleId, true, tools);
}
/**
* 최초 원격 조회 실패 후 local fallback을 사용 가능한 snapshot으로 채택합니다. 상태는 healthy로 위장하지 않고 fallback으로 남겨 운영자가 원격 원천 장애를 구분할 수 있게 합니다.
*/
synchronized BundleResult recordFallback(
String bundleId, List<ToolMetadata> tools, String revision, String failureReason) {
lastGood = tools;
this.revision = revision;
lastSuccessAt = Instant.now();
consecutiveFailures = 1;
lastFailureReason = failureReason;
fallbackSnapshot = true;
return new BundleResult(bundleId, true, tools);
}
/**
* usable snapshot이 이미 있는지 동기화해 확인합니다. 이 값은 local fallback을 최초 기동에만 제한하는 기준이며, 일반 조회 상태를 바꾸지 않습니다.
*/
synchronized boolean hasSnapshot() {
return lastGood != null;
}
/**
* 실패 횟수와 원인만 갱신하고 직전 성공본은 계속 노출합니다. 통신 실패만으로 Tool을 제거하지 않으며, 제거는 이후 정상 manifest에서 확인될 때만 반영합니다.
*/
synchronized BundleResult recordFailure(String bundleId, String reason) {
consecutiveFailures++;
lastFailureReason = reason;
return new BundleResult(bundleId, lastGood != null, lastGood == null ? List.of() : lastGood);
}
/**
* 현재 보관 중인 상태를 Actuator 조회용 요약으로 변환합니다.
*/
synchronized BundleStatus toStatus(Bundle bundle) {
String status;
if (!bundle.enabled()) {
status = "disabled";
} else if (fallbackSnapshot) {
status = "fallback";
} else if (consecutiveFailures > 0) {
status = "degraded";
} else if (lastGood != null) {
status = "healthy";
} else {
status = "unreachable";
}
return new BundleStatus(
bundle.id(),
bundle.enabled(),
status,
revision,
lastGood == null ? 0 : lastGood.size(),
consecutiveFailures,
Optional.ofNullable(lastSuccessAt).map(Instant::toString).orElse(null),
lastFailureReason);
}
}
}

View File

@@ -0,0 +1,87 @@
package io.shinhanlife.dap.biz.mcp.registry;
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.BundleResult;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 여러 Tool Service bundle의 조회 결과를 하나의 Tool 목록으로 병합하는 Registry 원천 adapter입니다. {@link ToolRegistryService}의 배경 갱신에서만 호출되며 요청 경로에는 관여하지 않습니다. 병합은
* {@code (bundleId, name)} 오름차순 정렬로 마무리해 동시 조회의 응답 순서가 {@code tools/list} 순서를 바꾸지 않게 합니다. 주요 의존성은 bundle별 조회·검증·상태를 담당하는 {@link ToolBundleDiscovery}와 상한
* 설정입니다.
*/
@Component
@ConditionalOnProperty(prefix = "mcp.discovery", name = "enabled", havingValue = "true")
public class ToolBundleRegistryClient implements ToolRegistryClient {
private final ToolBundleDiscovery discovery;
private final McpProperties properties;
/**
* bundle 조회 구성요소와 병합 상한 설정을 주입받습니다.
*/
public ToolBundleRegistryClient(ToolBundleDiscovery discovery, McpProperties properties) {
this.discovery = discovery;
this.properties = properties;
}
/**
* 활성 bundle을 모두 조회한 뒤 검증을 통과한 Tool을 병합해 반환합니다. 각 bundle이 이번 조회 결과 또는 직전 성공본을 가져야 전체 snapshot을 확정합니다. 하나라도 사용 가능한 성공본이 없으면 Registry unavailable을 던져,
* {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다.
*/
@Override
public List<ToolMetadata> fetchTools() {
List<BundleResult> results = discovery.discoverAll();
if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE,
"At least one Tool bundle has no usable snapshot");
}
return merge(results);
}
/**
* bundle별 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다. 이름이 겹치거나 총량 상한을 넘으면 불완전한 목록을 만들지 않고 전체 갱신을 거부합니다.
*/
private List<ToolMetadata> merge(List<BundleResult> results) {
List<BundleTool> candidates = new ArrayList<>();
for (BundleResult result : results) {
result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool)));
}
candidates.sort(
Comparator.comparing(BundleTool::bundleId).thenComparing(entry -> entry.tool().name()));
int maxTotal = properties.discovery().maxToolsTotal();
Set<String> names = new HashSet<>();
List<ToolMetadata> merged = new ArrayList<>();
for (BundleTool candidate : candidates) {
if (!names.add(candidate.tool().name())) {
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE,
"Duplicate Tool name across bundles: " + candidate.tool().name());
}
if (merged.size() >= maxTotal) {
throw new JsonRpcException(
JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE,
"Tool catalog exceeds maxToolsTotal: " + maxTotal);
}
merged.add(candidate.tool());
}
return List.copyOf(merged);
}
/**
* 정렬 기준인 소속 bundle을 Tool과 함께 들고 다니기 위한 병합 전용 임시 값입니다.
*/
private record BundleTool(String bundleId, ToolMetadata tool) {
}
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.biz.mcp.registry;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import tools.jackson.databind.JsonNode;
/**
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고,
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolMetadata(
String name,
String version,
String description,
String endpoint,
JsonNode inputSchema,
Integer timeoutMillis,
boolean enabled,
JsonNode publicDefinition) {
/**
* Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다.
*/
public int effectiveTimeoutMillis(int defaultTimeoutMillis) {
return timeoutMillis == null || timeoutMillis <= 0 ? defaultTimeoutMillis : timeoutMillis;
}
}

View File

@@ -0,0 +1,20 @@
package io.shinhanlife.dap.biz.mcp.registry;
import java.util.List;
/**
* Tool metadata의 원천(source)을 읽는 역할입니다.
*
* <p>이 interface는 cache가 아닙니다. {@link ToolRegistryService}가 요청 경로에서는 memory snapshot을 읽고, cold
* start 또는 배경 refresh 때만 구현체를 호출합니다. local profile은 JSON 파일을, 운영 profile은 설정된 Tool Service bundle의 매니페스트 aggregate를 원천으로 사용합니다. Redis는 원천이 아니라 기동 warm start와
* 성공 snapshot 공유에만 쓰는 선택적 cache입니다.
*
* <p>직접 MCP 요청을 처리하지 않는 outbound port이며, local 파일 구현과 운영 HTTP 구현을 profile에 따라 교체합니다.
*/
public interface ToolRegistryClient {
/**
* 현재 profile의 원천에서 Tool 전체 목록을 읽어 immutable 목록으로 반환합니다.
*/
List<ToolMetadata> fetchTools();
}

View File

@@ -0,0 +1,89 @@
package io.shinhanlife.dap.biz.mcp.registry;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 시작 시점과 설정된 주기에 Tool Registry cache를 선행 갱신하는 scheduler입니다. 기동 preload는 즉시 실행하고, 여러 replica의 반복 조회 쏠림은 첫 scheduled 실행의 jitter로 분산합니다. MCP 요청을 직접 처리하지 않으며
* {@link ToolRegistryService#refresh()}의 실패를 격리해 요청 시점의 정상 fallback을 보존합니다. 주요 의존성은 registry service와 Spring scheduling 설정입니다.
*/
@Component
public class ToolRegistryRefreshScheduler {
private static final Logger logger = LoggerFactory.getLogger(ToolRegistryRefreshScheduler.class);
private final ToolRegistryService registryService;
private volatile boolean firstAttemptCompleted;
/**
* Registry refresh를 실행할 service를 주입받습니다.
*/
public ToolRegistryRefreshScheduler(ToolRegistryService registryService) {
this.registryService = registryService;
}
/**
* 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신
* 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다.
*/
@EventListener(ApplicationReadyEvent.class)
public void preload() {
safeWarmStart();
safeRefresh("preload");
firstAttemptCompleted = true;
}
/**
* 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면
* 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다.
*/
public boolean firstAttemptCompleted() {
return firstAttemptCompleted;
}
/**
* 공유 cache warm start 실패를 격리해 기동을 막지 않게 합니다.
*/
private void safeWarmStart() {
try {
registryService.warmStartFromSharedCache();
} catch (RuntimeException exception) {
logger.warn(
"Tool Registry warm start failed: reason={}", exception.getClass().getSimpleName());
}
}
/**
* 설정된 간격마다 Tool Service manifest를 다시 읽어 cache snapshot을 갱신합니다. 첫 scheduled 실행에는 bounded random jitter를 더해 동시에 기동한 replica의 조회 시점을 분산합니다.
*/
@Scheduled(
fixedDelayString = "${mcp.registry.refresh-interval-seconds:30}",
initialDelayString =
"#{${mcp.registry.refresh-interval-seconds:30}"
+ " + T(java.util.concurrent.ThreadLocalRandom).current()"
+ ".nextLong(0, ${mcp.registry.refresh-jitter-seconds:5} + 1)}",
timeUnit = TimeUnit.SECONDS)
public void scheduledRefresh() {
safeRefresh("scheduled");
}
/**
* refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다.
*/
private void safeRefresh(String trigger) {
try {
registryService.refresh();
} catch (RuntimeException exception) {
// Cache preload/refresh is best-effort; request-time direct lookup remains available.
logger.warn(
"Tool Registry refresh failed: trigger={}, reason={}",
trigger,
exception.getClass().getSimpleName());
}
}
}

View File

@@ -0,0 +1,170 @@
package io.shinhanlife.dap.biz.mcp.registry;
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.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.stereotype.Service;
/**
* Tool Registry metadata 조회의 단일 진입점이며 요청 경로와 배경 갱신 경로를 분리하는 서비스입니다. {@code tools/list}와 {@code tools/call}의 요청 경로는 in-memory snapshot만 읽으므로 Redis 장애나 지연이 응답에
* 영향을 주지 않습니다. Redis는 배경 갱신과 warm start에서만 사용하는 replica 간 공유 지점이며, 원천 조회 성공 결과만 저장합니다. 주요 의존성은 원천 port {@link ToolRegistryClient}와 선택적 Redis cache입니다.
*/
@Service
public class ToolRegistryService {
private final ToolRegistryClient registryClient;
private final Optional<RedisToolRegistryCache> redisCache;
private final AtomicReference<List<ToolMetadata>> snapshot = new AtomicReference<>();
private final AtomicReference<CompletableFuture<List<ToolMetadata>>> refreshInFlight =
new AtomicReference<>();
/**
* 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient, Optional<RedisToolRegistryCache> redisCache) {
this.registryClient = registryClient;
this.redisCache = redisCache;
}
/**
* 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번
* 조회해 cold start 공백을 메웁니다.
*/
public List<ToolMetadata> listTools() {
List<ToolMetadata> memory = snapshot.get();
if (memory != null) {
return memory;
}
return refresh();
}
/**
* 요청을 처리할 수 있는 Tool snapshot이 memory에 적재됐는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 빈 목록도 유효한 전체 상태이므로 {@code null} 여부만 판단하며, readiness 확인 과정에서 Redis나 Tool Service를
* 호출하지 않습니다.
*/
public boolean hasUsableSnapshot() {
return snapshot.get() != null;
}
/**
* 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다.
*/
public void warmStartFromSharedCache() {
if (snapshot.get() != null) {
return;
}
redisCache
.flatMap(RedisToolRegistryCache::loadSnapshot)
.ifPresent(tools -> snapshot.compareAndSet(null, List.copyOf(tools)));
}
/**
* 표준 Tool 이름이 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String name) {
List<ToolMetadata> cached = listTools();
Optional<ToolMetadata> match = match(cached, name);
if (match.isPresent()) {
return match.get();
}
// A cache may be stale. Perform one direct lookup before declaring the tool missing.
try {
List<ToolMetadata> refreshed = refresh();
return match(refreshed, name).orElseThrow(() -> notFound(name));
} catch (JsonRpcException exception) {
if (exception.errorCode() == JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE
&& !cached.isEmpty()) {
throw notFound(name);
}
throw exception;
}
}
/**
* Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를
* 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다.
*/
public List<ToolMetadata> refresh() {
CompletableFuture<List<ToolMetadata>> candidate = new CompletableFuture<>();
CompletableFuture<List<ToolMetadata>> running =
refreshInFlight.compareAndExchange(null, candidate);
if (running != null) {
return awaitRefresh(running);
}
try {
List<ToolMetadata> tools = refreshOnce();
candidate.complete(tools);
return tools;
} catch (RuntimeException exception) {
candidate.completeExceptionally(exception);
throw exception;
} finally {
refreshInFlight.compareAndSet(candidate, null);
}
}
/**
* Tool 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다.
*/
private List<ToolMetadata> refreshOnce() {
try {
List<ToolMetadata> tools =
registryClient.fetchTools().stream().filter(ToolMetadata::enabled).toList();
snapshot.set(List.copyOf(tools));
redisCache.ifPresent(cache -> cache.saveSnapshot(tools));
return tools;
} catch (RuntimeException exception) {
List<ToolMetadata> memory = snapshot.get();
if (memory != null) {
return memory;
}
Optional<List<ToolMetadata>> shared =
redisCache.flatMap(RedisToolRegistryCache::loadSnapshot);
if (shared.isPresent()) {
snapshot.set(List.copyOf(shared.get()));
return shared.get();
}
throw exception;
}
}
/**
* 다른 호출이 시작한 refresh 결과를 기다리며 원래 RuntimeException 유형을 보존합니다. 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 사용합니다.
*/
private List<ToolMetadata> awaitRefresh(CompletableFuture<List<ToolMetadata>> refresh) {
try {
return refresh.join();
} catch (CompletionException exception) {
if (exception.getCause() instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw exception;
}
}
/**
* 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부됩니다.
*/
private Optional<ToolMetadata> match(List<ToolMetadata> tools, String name) {
return tools.stream()
.filter(ToolMetadata::enabled)
.filter(tool -> name.equals(tool.name()))
.findFirst();
}
/**
* 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다.
*/
private JsonRpcException notFound(String name) {
return new JsonRpcException(
JsonRpcErrorCode.TOOL_NOT_FOUND, "Tool not found or disabled: " + name);
}
}

View File

@@ -0,0 +1,186 @@
package io.shinhanlife.dap.biz.mcp.toolclient;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
import java.net.SocketTimeoutException;
import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.StringNode;
/**
* Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다. Registry 기반 {@code tools/call} 실행이 이 구현을 사용하며, 요청 context의 correlation·사원 식별자
* 헤더를 그대로 bypass하고 선택적 Authorization과 request deadline을 함께 전달합니다. 주요 의존성은 RestClient, 공유 JDK HttpClient, McpProperties, ObjectMapper입니다.
*/
@Component
public class HttpToolClient implements ToolClient {
private final ObjectMapper objectMapper;
private final McpProperties properties;
private final HttpClient toolHttpClient;
/**
* JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다.
*/
public HttpToolClient(
ObjectMapper objectMapper,
McpProperties properties,
@Qualifier("toolHttpClient") HttpClient toolHttpClient) {
this.objectMapper = objectMapper;
this.properties = properties;
this.toolHttpClient = toolHttpClient;
}
/**
* ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. correlation 헤더를 전달하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다.
*/
@Override
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
try {
RestClient.RequestBodySpec spec = requestSpec(request, context);
var entity =
spec.retrieve()
.onStatus(
HttpStatusCode::isError,
(httpRequest, response) -> {
throw statusException(response.getStatusCode().value(), request.toolName());
})
.toEntity(String.class);
return new ToolResponse(
entity.getStatusCode().value(),
parseResponse(entity.getBody(), entity.getHeaders().getContentType()));
} catch (ToolClientException exception) {
throw exception;
} catch (ResourceAccessException exception) {
if (hasTimeoutCause(exception)) {
throw new ToolClientException(
ToolClientException.Kind.TIMEOUT, "Tool timed out: " + request.toolName(), exception);
}
throw executionException(request, exception);
} catch (RestClientException | IllegalArgumentException exception) {
throw executionException(request, exception);
}
}
/**
* URI, bypass 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. Agent Builder가 보낸 correlation·사원 식별자는 이름과 값을 바꾸지 않고 그대로 실어 보냅니다. 사원 식별자는 암호문이며 MCP는 복호화하지 않으므로, 이
* 경계에서는 전달만 하고 해석하지 않습니다.
*/
private RestClient.RequestBodySpec requestSpec(ToolRequest request, McpRequestContext context) {
RestClient client = clientFor(remainingTimeoutMillis(request, context));
return client
.post()
.uri(request.endpoint())
.headers(
headers -> {
set(headers, "x-request-id", context.requestId());
set(headers, "guid", context.guid());
set(headers, "employee-no", context.employeeNo());
set(headers, "virtual-employee-no", context.virtualEmployeeNo());
set(headers, "mcp-session-id", context.mcpSessionId());
if (properties.toolClient().forwardAuthorization()) {
set(headers, "Authorization", context.authorization());
}
})
.contentType(MediaType.APPLICATION_JSON)
.body(request.arguments());
}
/**
* 공유 JDK HttpClient 위에 이번 호출의 read timeout만 적용한 경량 RestClient를 만듭니다.
*/
private RestClient clientFor(int readTimeoutMillis) {
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(toolHttpClient);
factory.setReadTimeout(Duration.ofMillis(readTimeoutMillis));
return RestClient.builder().requestFactory(factory).build();
}
/**
* Tool timeout과 전체 MCP deadline 중 더 짧은 남은 시간을 실제 read timeout으로 선택합니다.
*/
private int remainingTimeoutMillis(ToolRequest request, McpRequestContext context) {
long remainingMillis = context.remainingMillis();
if (remainingMillis <= 0) {
throw new ToolClientException(
ToolClientException.Kind.TIMEOUT,
"MCP request deadline exceeded before calling Tool: " + request.toolName(),
null);
}
return (int) Math.min(request.timeoutMillis(), remainingMillis);
}
/**
* 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다.
*/
private void set(org.springframework.http.HttpHeaders headers, String name, String value) {
if (value != null) {
headers.set(name, value);
}
}
/**
* upstream HTTP 상태를 권한 오류 또는 일반 실행 오류 ToolClientException으로 변환합니다.
*/
private ToolClientException statusException(int status, String toolName) {
ToolClientException.Kind kind =
switch (status) {
case 401 -> ToolClientException.Kind.UNAUTHORIZED;
case 403 -> ToolClientException.Kind.FORBIDDEN;
default -> ToolClientException.Kind.EXECUTION;
};
return new ToolClientException(kind, "Tool returned HTTP " + status + ": " + toolName, null);
}
/**
* 네트워크·직렬화 등 일반 client 예외를 Tool 이름이 포함된 실행 실패로 감쌉니다.
*/
private ToolClientException executionException(ToolRequest request, Exception exception) {
return new ToolClientException(
ToolClientException.Kind.EXECUTION, "Tool call failed: " + request.toolName(), exception);
}
/**
* 예외 cause chain 전체를 따라가며 실제 socket timeout이 포함되어 있는지 확인합니다.
*/
private boolean hasTimeoutCause(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof SocketTimeoutException) {
return true;
}
current = current.getCause();
}
return false;
}
/**
* Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다.
*/
private JsonNode parseResponse(String body, MediaType contentType) {
if (body == null) {
return null;
}
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
return StringNode.valueOf(body);
}
try {
return objectMapper.readTree(body);
} catch (Exception ignored) {
return StringNode.valueOf(body);
}
}
}

View File

@@ -0,0 +1,63 @@
package io.shinhanlife.dap.biz.mcp.toolclient;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import tools.jackson.databind.JsonNode;
/**
* 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해
* 반환합니다.
*/
public interface ToolClient {
/**
* ToolRequest를 한 번 실행하고 HTTP 상태와 응답 data를 반환하는 기본 Tool 호출 port입니다.
*/
ToolResponse execute(ToolRequest request, McpRequestContext context);
/**
* Tool Service로 전달할 URL, arguments, timeout을 묶는 불변 요청 값 객체입니다.
*/
record ToolRequest(
String toolName, String version, String endpoint, JsonNode arguments, int timeoutMillis) {
}
/**
* Tool Service 응답의 HTTP 상태와 JSON 또는 text로 정규화한 본문을 담는 불변 값 객체입니다.
*/
record ToolResponse(int statusCode, JsonNode data) {
}
/**
* upstream Tool 호출의 timeout·권한·일반 실행 실패를 실행 계층이 구분할 수 있게 전달하는 예외입니다. {@code ToolsCallHandler}는 이 정보를 거쳐 Tool 실패를 JSON-RPC error가 아닌 {@code result.isError}로
* 응답합니다.
*/
final class ToolClientException extends RuntimeException {
/**
* 실행 계층이 timeout·권한 거부·기타 호출 실패를 서로 다른 MCP 오류 의미로 바꾸기 위한 실패 분류입니다.
*/
public enum Kind {
TIMEOUT,
UNAUTHORIZED,
FORBIDDEN,
EXECUTION
}
private final Kind kind;
/**
* 실패 종류, 안전한 메시지와 원인 예외를 보존합니다.
*/
public ToolClientException(Kind kind, String message, Throwable cause) {
super(message, cause);
this.kind = kind;
}
/**
* timeout·권한·일반 실행 중 어떤 종류의 실패인지 반환합니다.
*/
public Kind kind() {
return kind;
}
}
}

View File

@@ -0,0 +1,107 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* 설정된 MCP endpoint의 요청 본문을 제한된 크기로 메모리에 복사하고 filter와 controller가 각각 다시 읽게 하는 servlet request wrapper입니다. {@link McpExchangeFilter}가 method 확인과 입력 크기 제한을 위해 만들며, 본문이
* 한도를 넘으면 controller까지 전달하지 않고 차단합니다. 주요 의존성은 Servlet request/stream API뿐이며, JSON-RPC method의 관찰용 해석은 {@link McpExchangeFilter}가 담당합니다.
*/
final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
/**
* 읽어 둔 요청 본문. 이 클래스 밖으로 배열 자체를 넘기지 않고 스트림으로만 노출합니다.
*/
private final byte[] body;
/**
* 원본 요청 본문을 설정된 최대 크기까지만 메모리에 읽어 둡니다.
*
* <p>한도보다 <b>1 byte 더</b> 읽는 이유는, 전체를 다 읽어 본 뒤에 크기를 재면 거대한 요청이 이미 메모리에 올라온 뒤이기 때문입니다. 한도+1을 읽어 그
* 길이가 한도를 넘으면 나머지는 읽지 않고 바로 차단합니다.
*
* @param maxBodyBytes 허용할 본문 최대 byte 수
* @throws RequestBodyTooLargeException 본문이 한도를 넘어 controller까지 보내지 않고 끊을 때
*/
CachedBodyHttpServletRequest(HttpServletRequest request, int maxBodyBytes) throws IOException {
super(request);
byte[] candidate = request.getInputStream().readNBytes(maxBodyBytes + 1);
if (candidate.length > maxBodyBytes) {
throw new RequestBodyTooLargeException(maxBodyBytes);
}
this.body = candidate;
}
/**
* 요청 본문을 읽을 수 있는 스트림을 <b>매번 새로</b> 만들어 돌려줍니다.
*
* <p>이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 <b>한 번 읽으면 끝</b>입니다. 그런데 이
* 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면
* 두 번 읽어도 문제가 없습니다.
*/
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream input = new ByteArrayInputStream(body);
return new ServletInputStream() {
/** 한 byte를 읽어 반환합니다. 더 읽을 것이 없으면 {@code -1}입니다. */
@Override
public int read() {
return input.read();
}
/** 본문을 끝까지 읽었는지 알려 줍니다. 메모리 배열이라 남은 byte 수로 바로 판단합니다. */
@Override
public boolean isFinished() {
return input.available() == 0;
}
/** 지금 바로 읽어도 되는지 알려 줍니다. 네트워크가 아니라 이미 메모리에 있는 데이터이므로 기다릴 일이 없어 항상 {@code true}입니다. */
@Override
public boolean isReady() {
return true;
}
/**
* 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를
* 던져 잘못된 사용을 드러냅니다.
*/
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException("Async request body reading is not supported");
}
};
}
/**
* 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다.
*/
@Override
public BufferedReader getReader() {
String encoding = getCharacterEncoding();
Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
/**
* 요청 본문이 설정된 한도를 넘었음을 알리는 내부 전용 예외입니다. {@link McpExchangeFilter}가 이 예외를 잡아 JSON-RPC {@code -32600 Invalid Request}로 바꾸며, controller까지 요청이 전달되지 않습니다. 이 클래스
* 밖에서는 만들 수 없습니다.
*/
static final class RequestBodyTooLargeException extends IOException {
/**
* 한도 값을 메시지에 담아, 로그만 보고도 어떤 설정 때문에 막혔는지 알 수 있게 합니다.
*/
private RequestBodyTooLargeException(int maxBodyBytes) {
super("MCP request body exceeds configured maximum of " + maxBodyBytes + " bytes");
}
}
}

View File

@@ -0,0 +1,76 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
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.jsonrpc.JsonRpcRequest;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.JsonNode;
/**
* 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. JSON-RPC 요청을 parser로 검증하고 handler로 dispatch하며, notification HTTP 202, initialize 세션 correlation
* 헤더, JSON 응답을 조립합니다. 주요 의존성은 endpoint 설정, request parser, handler registry와 request context입니다.
*/
@RestController
public class McpController {
public static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id";
private final JsonRpcRequestParser requestParser;
private final McpMethodHandlerRegistry handlerRegistry;
/**
* JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다.
*/
public McpController(
JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry) {
this.requestParser = requestParser;
this.handlerRegistry = handlerRegistry;
}
/**
* 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 알맞은 handler로 전달합니다. initialize에는 새 correlation header를 발급하고 notification은 HTTP 202, 일반 요청은 HTTP 200으로 응답합니다. mapping의
* {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않고 항상 단일 JSON 또는 빈 notification 응답을
* 반환합니다.
*/
@PostMapping(
value = "${mcp.endpoint-path:/mcp}",
consumes = {MediaType.APPLICATION_JSON_VALUE, "application/json-rpc"},
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_EVENT_STREAM_VALUE})
public ResponseEntity<?> handleMcpRequest(@RequestBody JsonNode envelope) {
McpRequestContext context = McpRequestContextHolder.require();
JsonRpcRequest request = requestParser.parse(envelope);
try {
JsonRpcResponse response = handlerRegistry.resolve(request.method()).handle(request, context);
if (request.notification()) {
return ResponseEntity.accepted().build();
}
ResponseEntity.BodyBuilder responseBuilder =
ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON);
if ("initialize".equals(request.method())) {
responseBuilder.header(MCP_SESSION_ID_HEADER, UUID.randomUUID().toString());
}
return responseBuilder.body(response);
} catch (JsonRpcException exception) {
if (exception.requestId() != null) {
throw exception;
}
throw new JsonRpcException(
exception.errorCode(), exception.errorData(), request.id(), exception);
} catch (RuntimeException exception) {
throw new JsonRpcException(
JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error", request.id(), exception);
}
}
}

View File

@@ -0,0 +1,87 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
import java.util.Set;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* {@link McpController} 처리 중 발생한 예외를 AgentBuilder가 해석할 JSON-RPC 오류 응답으로 정규화하는 전용 예외 처리기입니다. 설정된 MCP endpoint의 malformed JSON, 검증 오류, 예상치 못한 controller 오류를 HTTP 200
* 안의 JSON-RPC error envelope로 반환합니다. Filter 단계의 크기·헤더·protocol 오류는 MVC에 도달하지 않으므로 {@link McpExchangeFilter}가 직접 응답합니다. 다만 지원하지 않는 HTTP method는 JSON-RPC 이전의
* transport 문제이므로 표준 HTTP 405로 응답합니다. 주요 의존성은 오류 코드 factory와 {@link TraceLogger}이며, Tool 실행 실패의 {@code result.isError} 변환은 이 클래스가 아니라 {@code ToolsCallHandler}가
* 담당합니다.
*/
@RestControllerAdvice(assignableTypes = McpController.class)
public class McpExceptionHandler {
private final TraceLogger traceLogger;
/**
* 모든 오류를 같은 trace 형식으로 기록하기 위해 logger를 주입받습니다.
*/
public McpExceptionHandler(TraceLogger traceLogger) {
this.traceLogger = traceLogger;
}
/**
* 서버가 의도적으로 발생시킨 JSON-RPC 예외를 HTTP 200의 표준 실패 응답으로 변환합니다.
*/
@ExceptionHandler(JsonRpcException.class)
public ResponseEntity<JsonRpcResponse> handleJsonRpcException(JsonRpcException exception) {
traceLogger.error("error_occurred", exception, "errorCode", exception.errorCode().code());
return ResponseEntity.ok(
JsonRpcResponse.failure(
exception.requestId(), exception.errorCode(), exception.errorData()));
}
/**
* JSON 문법이 잘못되어 body를 읽지 못한 경우 Parse error 응답을 반환합니다.
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<JsonRpcResponse> handleParseError(
HttpMessageNotReadableException exception) {
traceLogger.error(
"error_occurred", exception, "errorCode", JsonRpcErrorCode.PARSE_ERROR.code());
return ResponseEntity.ok(
JsonRpcResponse.failure(null, JsonRpcErrorCode.PARSE_ERROR, "Malformed JSON request body"));
}
/**
* 설정된 MCP endpoint가 허용하지 않는 HTTP method 요청을 JSON-RPC 오류가 아니라 표준 HTTP 405로 반환합니다. MCP 클라이언트는 server-push 수신용 GET이나 세션 종료용 DELETE를 시도할 수
* 있는데, 이 서버는 POST 단일 경로만 제공하므로 지원 method를 {@code Allow} 헤더로 알려 클라이언트가 재시도하지 않게 합니다. 이 응답은 JSON-RPC 이전 단계의 transport 계약이므로 본문 없이 상태 코드와 헤더만 반환합니다.
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Void> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException exception) {
traceLogger.error(
"mcp_http_method_not_allowed", exception, "httpMethod", exception.getMethod());
HttpHeaders headers = new HttpHeaders();
Set<HttpMethod> supportedMethods = exception.getSupportedHttpMethods();
if (supportedMethods != null && !supportedMethods.isEmpty()) {
headers.setAllow(supportedMethods);
}
return new ResponseEntity<>(headers, HttpStatus.METHOD_NOT_ALLOWED);
}
/**
* 예상하지 못한 예외의 내부 내용을 숨기고 안전한 Internal error 응답으로 변환합니다.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<JsonRpcResponse> handleUnexpected(Exception exception) {
traceLogger.error(
"error_occurred", exception, "errorCode", JsonRpcErrorCode.INTERNAL_ERROR.code());
return ResponseEntity.ok(
JsonRpcResponse.failure(null, JsonRpcErrorCode.INTERNAL_ERROR, "Unexpected server error"));
}
}

View File

@@ -0,0 +1,214 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
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.jsonrpc.JsonRpcResponse;
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 필터입니다. Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다.
* payload, credential, 사원 식별자는 로그에 저장하지 않습니다. 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class McpExchangeFilter extends OncePerRequestFilter {
private final McpRequestContextFactory headerExtractor;
private final TraceLogger traceLogger;
private final ObjectMapper objectMapper;
private final McpProperties properties;
private final McpProtocolVersionValidator protocolVersionValidator;
/**
* 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 별도 payload capture나 audit sink는 조립하지 않습니다.
*/
public McpExchangeFilter(
McpRequestContextFactory headerExtractor,
TraceLogger traceLogger,
ObjectMapper objectMapper,
McpProperties properties,
McpProtocolVersionValidator protocolVersionValidator) {
this.headerExtractor = headerExtractor;
this.traceLogger = traceLogger;
this.objectMapper = objectMapper;
this.properties = properties;
this.protocolVersionValidator = protocolVersionValidator;
}
/**
* 설정된 MCP endpoint 이외의 다른 MCP·health·management 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다.
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
return !properties.endpointPath().equals(path);
}
/**
* MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다.
*
* <p>처리 순서는 다음과 같습니다.
*
* <ol>
* <li>헤더에서 correlation 값을 뽑아 context를 만들고 응답 헤더에 먼저 심는다(오류 응답에도 실리도록)
* <li>본문을 크기 제한과 함께 읽어 다시 읽을 수 있는 wrapper로 감싼다
* <li>로그·검증용으로 JSON-RPC method만 미리 확인한다
* <li>protocol version을 검증하고, 실패하면 controller까지 가지 않고 HTTP 400으로 끝낸다
* <li>controller 체인을 실행하고 응답 완료 로그를 남긴다
* </ol>
*
* <p>어떤 경로로 끝나든 {@code finally}에서 ThreadLocal을 지웁니다. thread는 다음 요청에 재사용되므로, 지우지 않으면 이전 요청의 사용자
* 정보가 섞입니다.
*/
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long startedNanos = System.nanoTime();
try {
McpRequestContext context = headerExtractor.extract(request);
McpRequestContextHolder.set(context);
response.setHeader("guid", context.guid());
response.setHeader("x-request-id", context.requestId());
CachedBodyHttpServletRequest cachedRequest =
new CachedBodyHttpServletRequest(request, properties.trace().maxBodyBytes());
String mcpMethod = extractMethod(cachedRequest);
traceLogger.event(
"mcp_http_request_received",
"httpMethod",
request.getMethod(),
"path",
request.getRequestURI(),
"mcpMethod",
mcpMethod);
try {
protocolVersionValidator.validatePostInitializeRequest(request, mcpMethod);
} catch (ProtocolVersionException exception) {
writeProtocolVersionError(response, context, exception);
traceLogger.event(
"mcp_http_response_completed",
"mcpMethod",
mcpMethod,
"httpStatus",
response.getStatus(),
"durationMillis",
elapsedMillis(startedNanos));
return;
}
filterChain.doFilter(cachedRequest, response);
traceLogger.event(
"mcp_http_response_completed",
"mcpMethod",
mcpMethod,
"httpStatus",
response.getStatus(),
"durationMillis",
elapsedMillis(startedNanos));
} catch (CachedBodyHttpServletRequest.RequestBodyTooLargeException exception) {
traceLogger.error(
"mcp_http_request_rejected",
exception,
"maxBodyBytes",
properties.trace().maxBodyBytes());
writeJsonRpcError(response, JsonRpcErrorCode.INVALID_REQUEST, exception.getMessage());
} catch (JsonRpcException exception) {
traceLogger.error(
"mcp_http_request_rejected", exception, "errorCode", exception.errorCode().code());
writeJsonRpcError(response, exception.errorCode(), exception.errorData());
} catch (IOException exception) {
// 여기까지 온 IOException은 대개 "쓰려는데 상대가 이미 끊었다"(broken pipe)다.
// Agent Builder는 응답을 5분 이상 기다리지 않으므로, 오래 걸린 Tool 결과가 이 경로로 버려진다.
// 조용히 사라지면 나중에 추적이 불가능하므로 guid와 함께 별도 event로 남긴다.
// Tool은 이미 실행됐을 수 있다. 재시도 중복 실행 방지는 Tool Service 몫이며 guid 재사용 규칙은 별도 합의 대상이다.
traceLogger.error(
"mcp_http_response_undeliverable",
exception,
"durationMillis",
elapsedMillis(startedNanos));
throw exception;
} finally {
McpRequestContextHolder.clear();
}
}
/**
* 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다.
*
* <p>여기서 읽어도 controller가 같은 본문을 다시 읽을 수 있습니다. {@link CachedBodyHttpServletRequest}가 호출할 때마다 새
* 스트림을 만들어 주기 때문입니다.
*
* <p>JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려줍니다. 이 단계는 <b>관찰</b>이 목적이고, 잘못된 JSON을 어떤 오류로 응답할지는
* 뒤쪽 request adapter가 정하기 때문입니다. 여기서 먼저 실패시키면 오류 계약이 두 곳으로 갈라집니다.
*
* @return method 이름. 읽을 수 없으면 {@code null}
*/
private String extractMethod(CachedBodyHttpServletRequest request) {
try {
JsonNode envelope = objectMapper.readTree(request.getInputStream());
return envelope == null ? null : envelope.path("method").asString(null);
} catch (Exception ignored) {
return null;
}
}
/**
* 필터 단계의 JSON-RPC 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다.
*/
private void writeJsonRpcError(
HttpServletResponse response, JsonRpcErrorCode code, Object details) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(), JsonRpcResponse.failure(null, code, details));
}
/**
* initialize 이후 protocol version 헤더 누락·불일치를 HTTP 400 transport 오류로 작성합니다.
*/
private void writeProtocolVersionError(
HttpServletResponse response, McpRequestContext context, ProtocolVersionException exception)
throws IOException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
Map.of(
"error", "Invalid MCP protocol version",
"message", exception.getMessage(),
"supportedVersions", properties.protocol().supportedVersions(),
"guid", context.guid()));
}
/**
* 요청 시작 이후 경과 시간을 monotonic clock 기준 밀리초로 반환합니다.
*/
private long elapsedMillis(long startedNanos) {
return (System.nanoTime() - startedNanos) / 1_000_000L;
}
}

View File

@@ -0,0 +1,55 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* initialize 이후 MCP 요청이 합의된 protocol version 헤더를 선언했는지 검사하는 stateless 정책 컴포넌트입니다. 설정된 MCP endpoint의 가장 앞단 filter가 호출하며 initialize 자체는 협상 단계이므로 검사 대상에서 제외합니다. 주요
* 의존성은 지원·선호 버전 목록을 제공하는 {@link McpProperties}와 MCP SDK method 상수입니다.
*/
@Component
public class McpProtocolVersionValidator {
public static final String HEADER_NAME = "MCP-Protocol-Version";
private final McpProperties properties;
/**
* 서버가 지원하는 MCP protocol version 설정을 주입받습니다.
*/
public McpProtocolVersionValidator(McpProperties properties) {
this.properties = properties;
}
/**
* initialize 이후 요청에 MCP-Protocol-Version 헤더가 있는지, 지원 목록과 일치하는지 검사합니다. initialize 자체는 아직 version을 협상하는 단계이므로 검사하지 않습니다.
*/
public void validatePostInitializeRequest(HttpServletRequest request, String mcpMethod) {
if (mcpMethod == null || McpSchema.METHOD_INITIALIZE.equals(mcpMethod)) {
return;
}
String version = request.getHeader(HEADER_NAME);
if (!StringUtils.hasText(version)) {
throw new ProtocolVersionException(HEADER_NAME + " header is required after initialize");
}
if (!properties.protocol().supportedVersions().contains(version.trim())) {
throw new ProtocolVersionException("Unsupported " + HEADER_NAME + ": " + version.trim());
}
}
/**
* protocol version 헤더 누락 또는 미지원 값을 filter가 JSON-RPC 오류로 변환하도록 전달하는 내부 예외입니다. 별도의 HTTP 응답을 만들지 않으며, 최종 응답 형식은 {@code McpExchangeFilter}의 책임입니다.
*/
public static final class ProtocolVersionException extends RuntimeException {
/**
* 호출자에게 알려 줄 protocol version 거절 이유를 보존합니다.
*/
public ProtocolVersionException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,135 @@
package io.shinhanlife.dap.biz.mcp.transport.http;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.util.UUID;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 설정된 MCP endpoint의 HTTP 헤더를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. filter의 가장 앞 단계에서 호출되며 {@code guid}·{@code x-request-id}를 생성 또는 검증하고,
* {@code mcp-session-id}·사원 식별자·deadline을 함께 정리합니다. 사원 식별자는 호출자가 암호화해 보낸 불투명 값이므로 형식·의미를 해석하지 않고 주입 위험 문자만 차단합니다. 주요 의존성은 timeout 설정 {@link McpProperties}이며,
* Authorization 원문은 context 전달 외에는 로그에 남기지 않습니다.
*/
@Component
public class McpRequestContextFactory {
private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
/**
* 암호문은 Base64라 {@code +/=}를 포함한다. 공백·제어문자만 막아 header 주입을 차단하고 내용은 해석하지 않는다.
*/
private static final Pattern SAFE_OPAQUE_TOKEN = Pattern.compile("[\\x21-\\x7E]{1,2048}");
private final McpProperties properties;
/**
* 요청 전체 timeout 설정을 주입받습니다.
*/
public McpRequestContextFactory(McpProperties properties) {
this.properties = properties;
}
/**
* HTTP 헤더를 읽어 correlation·세션·사원 식별자를 하나의 immutable context로 만듭니다. 다섯 헤더 모두 선택값이며, 로그 상관이 끊기지 않도록 {@code guid}와 {@code x-request-id}만 없을 때 새로 만듭니다. 전체 요청
* deadline도 이 시점에 계산합니다.
*/
public McpRequestContext extract(HttpServletRequest request) {
String authorization = trimToNull(request.getHeader("Authorization"));
String requestId = validatedRequestIdOrGenerated(request.getHeader("x-request-id"));
String guid = validatedGuidOrGenerated(request.getHeader("guid"));
String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id");
String employeeNo = opaqueOptional(request.getHeader("employee-no"), "employee-no");
String virtualEmployeeNo =
opaqueOptional(request.getHeader("virtual-employee-no"), "virtual-employee-no");
return new McpRequestContext(
requestId,
guid,
sessionId,
employeeNo,
virtualEmployeeNo,
authorization,
Instant.now().plusMillis(properties.toolClient().requestDeadlineMillis()));
}
/**
* {@code x-request-id}가 있으면 안전성을 검증하고, 없으면 {@code req-UUID} 형식으로 새 값을 만듭니다. 이 값은 개별 HTTP 요청을 구분하며 end-to-end 상관 값인 {@code guid}와 역할이 다릅니다.
*/
private String validatedRequestIdOrGenerated(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
return "req-" + UUID.randomUUID();
}
validate(normalized, "x-request-id");
return normalized;
}
/**
* {@code guid}가 없으면 표준 UUID를 만들고, 있으면 축약형이나 임의 문자열이 아닌 정규 UUID인지 확인합니다. Agent Builder가 보낸 값은 변경하지 않고 그대로 응답과 Tool Service 호출에 사용합니다.
*/
private String validatedGuidOrGenerated(String value) {
if (value == null || value.isEmpty()) {
return UUID.randomUUID().toString();
}
try {
if (!UUID.fromString(value).toString().equalsIgnoreCase(value)) {
throw new IllegalArgumentException("non-canonical UUID");
}
return value;
} catch (IllegalArgumentException exception) {
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "guid must be a UUID");
}
}
/**
* 암호화된 사원 식별자처럼 MCP가 해석하지 않는 값을 검증합니다. 값의 의미는 보지 않고, 개행·공백이 섞여 downstream 요청 헤더가 조작되는 것만 막습니다. 빈 값은 선택 헤더가 없는 것으로 취급하고 실제 암호문은 한 글자도 변경하지 않습니다.
*/
private String opaqueOptional(String value, String header) {
if (value == null || value.isEmpty()) {
return null;
}
if (!SAFE_OPAQUE_TOKEN.matcher(value).matches()) {
throw new JsonRpcException(
JsonRpcErrorCode.INVALID_REQUEST,
header + " must be a single-line token of at most 2048 printable characters");
}
return value;
}
/**
* 선택 헤더는 값이 있을 때만 형식 검증을 수행하고, 없으면 null을 반환합니다.
*/
private String validatedOptional(String value, String header) {
String normalized = trimToNull(value);
if (normalized != null) {
validate(normalized, header);
}
return normalized;
}
/**
* correlation 값이 허용된 문자와 1~128자 길이 규칙을 지키는지 검사합니다.
*/
private void validate(String value, String header) {
if (!SAFE_CORRELATION_ID.matcher(value).matches()) {
throw new JsonRpcException(
JsonRpcErrorCode.INVALID_REQUEST,
header + " must contain 1-128 safe correlation characters");
}
}
/**
* 공백 문자열을 null로 정규화하고 실제 값은 앞뒤 공백을 제거합니다.
*/
private String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}

View File

@@ -0,0 +1,17 @@
mcp:
discovery:
# 로컬에서도 먼저 Tool Service manifest를 조회하고, 최초 조회 실패 시 아래 bundle의 fallback 파일을 사용한다.
enabled: true
bundles:
- id: ${MCP_TOOL_BUNDLE_ID:core}
manifest-url: ${MCP_TOOL_MANIFEST_URL:http://localhost:18080/tool-manifest}
base-endpoint: ${MCP_TOOL_BASE_ENDPOINT:http://localhost:18080/mcp}
name-prefix: ${MCP_TOOL_NAME_PREFIX:core.}
fallback-manifest-file: ${MCP_FALLBACK_MANIFEST_FILE:file:./config/local-core-tools-manifest-sample-v1.json}
enabled: true
redis:
enabled: false
management:
health:
redis:
enabled: false

View File

@@ -0,0 +1,13 @@
mcp:
identity: ${MCP_IDENTITY}
discovery:
enabled: true
redis:
enabled: true
management:
server:
port: ${MANAGEMENT_SERVER_PORT:9090}
health:
redis:
enabled: false

View File

@@ -0,0 +1,91 @@
spring:
application:
name: ax-hub-mcp-server
profiles:
active: local
threads:
virtual:
enabled: true
lifecycle:
# Drain in-flight requests before the pod dies. A tools/call can run for one Tool timeout
# (max 30s) plus response write, so the 30s default is too short and would kill requests
# whose Tool already executed. Must stay BELOW terminationGracePeriodSeconds (45s).
timeout-per-shutdown-phase: 40s
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
# Redis is a shared cache, never the source of truth. A slow Redis must not slow the
# background refresh, so these timeouts are deliberately far shorter than the Tool timeouts.
connect-timeout: 200ms
timeout: 200ms
server:
port: ${SERVER_PORT:8080}
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,toolBundles
endpoint:
health:
probes:
enabled: true
group:
# Accept traffic only after the first discovery attempt and a usable in-memory snapshot.
# A last-good memory/Redis snapshot remains usable when the Tool Service is temporarily unavailable.
readiness:
include: readinessState,toolCatalog
mcp:
# Identifies this MCP deployment. Used to namespace the shared Redis cache so that
# several MCP servers can share one Redis without overwriting each other.
identity: ${MCP_IDENTITY:local-mcp}
# 각 컨테이너가 직접 처리하는 공개 MCP path. OpenShift Route는 이 값을 rewrite하지 않는다.
endpoint-path: ${MCP_ENDPOINT_PATH:/mcp}
server:
name: shl-axhub-mcp-server
title: SHL AX HUB MCP Server
version: 1.0.0
protocol:
supported-versions:
- "2025-06-18"
preferred-version: "2025-06-18"
registry:
# local profile uses this file instead of opening a separate Registry HTTP port.
local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:file:./config/local-core-tools-manifest-sample-v1.json}
refresh-interval-seconds: 30
refresh-jitter-seconds: 5
tool-client:
connect-timeout-millis: 1000
read-timeout-millis: 5000
# One Agent Builder -> MCP request budget. Agent Builder drops the connection at 300s,
# so MCP must give up FIRST or its answer arrives after nobody is listening.
# 270s leaves a 30s margin to serialize and write the timeout response.
request-deadline-millis: 270000
forward-authorization: false
redis:
enabled: true
key-prefix: axhub:mcp:tools
discovery:
# local=false uses the local JSON fixture; non-local deployments must enable Tool Service manifest pull.
enabled: ${MCP_DISCOVERY_ENABLED:false}
connect-timeout-millis: 1000
read-timeout-millis: 3000
max-tools-per-bundle: 100
max-tools-total: 200
max-manifest-bytes: 1048576
# Upper bound applied to the timeout a manifest declares, so one Tool cannot consume the whole request budget.
max-tool-timeout-millis: 30000
# Declared per deployment. baseEndpoint is the execution address and is owned by this file only:
# nothing a Tool Service returns can change where MCP sends the call.
bundles: []
trace:
enabled: true
# Rejects oversized MCP request bodies before controller processing.
max-body-bytes: 1048576
logging:
config: classpath:logback-spring.xml

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} level=%-5level service=${spring.application.name:-ax-hub-mcp-server} logger=%logger{36} msg=%msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('\\', '/');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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