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