diff --git a/README.md b/README.md index 1cc7526..a044291 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. U | 환경 | Tool 원천 | Redis | |---|---|---| | `local` | local JSON fixture | 사용 안 함 | -| 운영(`ocp`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | +| 운영(`prod`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | 요청 경로의 `tools/list`와 `tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 모든 bundle에 사용 가능한 성공본이 있을 때만 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. 코드는 bundle N개 병합을 지원하지만 **운영 배포의 bundle은 항상 하나다**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). diff --git a/deploy/helm/mcp-server/templates/configmap.yaml b/deploy/helm/mcp-server/templates/configmap.yaml index 5c39141..be7b2bf 100644 --- a/deploy/helm/mcp-server/templates/configmap.yaml +++ b/deploy/helm/mcp-server/templates/configmap.yaml @@ -1,5 +1,5 @@ # 배포별로 달라지는 설정만 담는다. -# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고, +# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-prod.yml이 소유하고, # 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다. {{- include "mcp-server.validate" . }} {{- $deployment := index .Values.deployments .Values.deploymentKey }} @@ -11,7 +11,7 @@ metadata: labels: {{- include "mcp-server.labels" . | nindent 4 }} data: - application-ocp.yml: | + application-prod.yml: | mcp: # "{배포 이름}-{global.env}"로 조립된다. 환경끼리 Redis key가 겹치지 않는다. identity: {{ include "mcp-server.identity" . }} diff --git a/deploy/helm/mcp-server/templates/deployment.yaml b/deploy/helm/mcp-server/templates/deployment.yaml index 1b9af6c..4bcd219 100644 --- a/deploy/helm/mcp-server/templates/deployment.yaml +++ b/deploy/helm/mcp-server/templates/deployment.yaml @@ -47,7 +47,7 @@ spec: containerPort: {{ .Values.ports.management }} env: - name: SPRING_PROFILES_ACTIVE - value: ocp + value: prod # ConfigMap을 jar 안의 설정보다 우선 적용한다. - name: SPRING_CONFIG_ADDITIONAL_LOCATION value: file:/opt/app/config/ diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java index b58938c..9deae55 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java @@ -9,9 +9,19 @@ 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 - * 컨테이너로 전달됩니다. + * @package io.shinhanlife.dat.biz.mcp + * @className McpServerApplication + * @description AX HUB MCP Server의 Spring Boot 애플리케이션 시작점입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @SpringBootApplication @ConfigurationPropertiesScan @@ -20,16 +30,17 @@ public class McpServerApplication { /** * Spring Boot 애플리케이션을 시작하는 최초 진입점입니다. 전달받은 실행 인자를 Spring에 넘기고 component scan과 설정 로딩을 시작합니다. + * + * @param args 애플리케이션 실행 인자입니다. */ 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을 자동 구성하지 - * 않습니다. + * Tool inputSchema와 arguments를 JSON Schema 2020-12 기준으로 검증할 MCP SDK 검증기를 한 번 생성합니다. Tool 실행 전 검증 계층에서만 사용하며 MCP HTTP transport나 서버 lifecycle을 자동 구성하지 않습니다. * - * @return schema 컴파일 결과를 재사용하는 MCP SDK 검증기 + * @return 처리 결과를 반환합니다. */ @Bean JsonSchemaValidator mcpJsonSchemaValidator() { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java index 20f8eff..59e5b2b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java @@ -10,14 +10,28 @@ 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입니다. + * @package io.shinhanlife.dat.biz.mcp.config + * @className HttpClientConfig + * @description Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Configuration public class HttpClientConfig { /** - * 여러 Tool 호출이 TCP 연결을 재사용할 수 있도록 공유 JDK HTTP client를 만듭니다. Tool별 read timeout은 이 객체를 새로 만들지 않고 HttpToolClient 쪽에서 적용합니다. + * Tool Service 실행 호출에 사용할 공유 JDK HTTP client를 생성합니다. + * + * @param properties MCP 설정 정보입니다. + * @return Tool Service 호출용 HTTP client를 반환합니다. */ @Bean @Qualifier("toolHttpClient") @@ -28,8 +42,10 @@ public class HttpClientConfig { } /** - * Tool Service bundle 매니페스트 조회 전용 RestClient를 생성합니다. 조회 대상이 bundle마다 다르므로 base URL을 두지 않고 매 호출에서 전체 {@code manifestUrl}을 사용합니다. timeout은 Tool 실행보다 짧게 잡아, - * 느린 bundle 하나가 전체 조회 주기를 잡아먹지 않게 합니다. + * Portal Registry와 Tool Service manifest 조회에 사용할 RestClient를 생성합니다. + * + * @param properties MCP 설정 정보입니다. + * @return manifest 조회용 RestClient를 반환합니다. */ @Bean @Qualifier("manifestRestClient") diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java index 78b10c8..651bba1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java @@ -13,8 +13,19 @@ 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 정책을 분리합니다. + * @package io.shinhanlife.dat.biz.mcp.config + * @className McpProperties + * @description {@code application.yml}의 {@code mcp.*} 설정을 타입 안전한 불변 객체로 묶고 시작 시 유효성을 검증하는 구성 계약입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Validated @ConfigurationProperties(prefix = "mcp") @@ -36,15 +47,18 @@ public record McpProperties( List<@Valid Bundle> bundles) { /** - * 선언되지 않은 bundle 목록을 빈 목록으로 정규화해 이후 코드가 null을 검사하지 않게 합니다. + * 선택 설정이 생략된 테스트·로컬 구성에서도 MCP가 최소 기본값으로 기동할 수 있게 보정합니다. 외부 원천 주소는 임의 생성하지 않고, Tool 호출 timeout과 retry처럼 안전한 내부 기본값만 채웁니다. */ public McpProperties { bundles = bundles == null ? List.of() : List.copyOf(bundles); + toolClient = toolClient == null ? ToolClient.defaults() : toolClient; portal = portal == null ? new Portal(false, "", "", 300) : portal; } /** * 조회 대상으로 켜져 있는 bundle만 골라 반환합니다. 조회·병합·Actuator 상태가 같은 목록을 사용합니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public record McpRequestContext( String routeKey, @@ -27,17 +36,14 @@ public record McpRequestContext( String authorization, Instant deadline) { - /** - * deadline이 없는 context를 허용하되 이미 만료된 것으로 취급합니다. - * 정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우며, 잘못 만들어진 context가 흘러들어오면 Tool 호출이 즉시 timeout으로 실패하도록 보수적으로 정규화합니다. - */ public McpRequestContext { deadline = deadline == null ? Instant.now() : deadline; } /** - * 이 요청에 남은 시간을 밀리초로 알려 줍니다. - * Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. + * 이 요청에 남은 시간을 밀리초로 알려 줍니다. Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. + * + * @return 계산된 숫자 값을 반환합니다. */ public long remainingMillis() { return Duration.between(Instant.now(), deadline).toMillis(); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java index b619689..3baf03e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.context; import java.util.Optional; /** - * 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. {@code McpExchangeFilter}가 설정하고 정상·예외 완료 시 제거합니다. 요청 밖에서 context를 보관하거나 서버 세션 상태로 - * 사용하면 안 되는 correlation 전용 유틸리티입니다. + * @package io.shinhanlife.dat.biz.mcp.context + * @className McpRequestContextHolder + * @description 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public final class McpRequestContextHolder { @@ -12,12 +23,15 @@ public final class McpRequestContextHolder { /** * 인스턴스를 만들 수 없는 정적 유틸리티 클래스임을 명확히 합니다. + * */ private McpRequestContextHolder() { } /** * 현재 요청을 처리하는 thread에 request context를 저장합니다. + * + * @param context 현재 MCP 요청 context입니다. */ public static void set(McpRequestContext context) { CONTEXT.set(context); @@ -25,6 +39,8 @@ public final class McpRequestContextHolder { /** * 현재 thread의 request context를 Optional로 안전하게 조회합니다. + * + * @return 조회된 선택값을 반환합니다. */ public static Optional
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class ToolArgumentValidator { @@ -22,6 +32,9 @@ public class ToolArgumentValidator { /** * JSON 변환용 Jackson mapper와 MCP SDK JSON Schema 검증기를 주입받습니다. 검증기는 Spring singleton으로 생성되어 동일한 Tool schema 컴파일 결과를 재사용합니다. + * + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param jsonSchemaValidator 협력 객체입니다. */ public ToolArgumentValidator(ObjectMapper objectMapper, JsonSchemaValidator jsonSchemaValidator) { this.objectMapper = objectMapper; @@ -29,16 +42,20 @@ public class ToolArgumentValidator { } /** - * Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 - * 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다. + * Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다. + * + * @param call Tool 처리 정보입니다. + * @param metadata Tool 처리 정보입니다. */ 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 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다. + * Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다. SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다. + * + * @param call Tool 처리 정보입니다. + * @param schema 입력값입니다. */ private void validateInputSchema(ToolCall call, JsonNode schema) { if (schema == null || schema.isNull()) { @@ -56,8 +73,10 @@ public class ToolArgumentValidator { } /** - * 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가 - * 담당합니다. + * 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가 담당합니다. + * + * @param call Tool 처리 정보입니다. + * @param schema 입력값입니다. */ private void validateStableContract(ToolCall call, JsonNode schema) { if (schema.has("type") && !"object".equals(schema.path("type").asText())) { @@ -90,6 +109,10 @@ public class ToolArgumentValidator { /** * 기존 기본 JSON 타입 오류 문구를 보존하면서 각 arguments 값의 선언 타입을 검사합니다. + * + * @param field 처리할 값입니다. + * @param type 입력값입니다. + * @param value 처리할 값입니다. */ private void validateStableType(String field, String type, JsonNode value) { if (type == null) { @@ -112,6 +135,9 @@ public class ToolArgumentValidator { /** * 검증 실패 이유를 Invalid params JSON-RPC 예외로 통일합니다. + * + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException invalid(String details) { return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java index 4310dd9..72b5935 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.execute; import com.fasterxml.jackson.databind.JsonNode; /** - * MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON - * 구조를 보존해 이후 schema 검증과 Tool Service 호출에 사용합니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolCall + * @description MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public record ToolCall(String toolName, JsonNode arguments) { } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java index 472bbb8..019a784 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java @@ -17,8 +17,19 @@ import java.util.concurrent.ConcurrentMap; import org.springframework.stereotype.Service; /** - * MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry, - * argument validator, routing service, {@link ToolClient}와 Tool HTTP 호출·응답 경계를 기록하는 trace logger입니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolExecutionService + * @description MCP Tool 실행의 orchestration 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Service public class ToolExecutionService { @@ -34,6 +45,12 @@ public class ToolExecutionService { /** * metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다. + * + * @param registryService Tool metadata 조회 서비스입니다. + * @param argumentValidator Tool arguments 검증기입니다. + * @param routingService Tool 호출 요청 변환 서비스입니다. + * @param toolClient Tool Service 호출 client입니다. + * @param traceLogger 경계 이벤트 로그 기록기입니다. */ public ToolExecutionService( ToolRegistryService registryService, @@ -49,43 +66,77 @@ public class ToolExecutionService { } /** - * Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 한 번 실행합니다. 처리 순서는 Registry 조회 → inputSchema 검증 → endpoint/timeout 확정 → ToolClient 호출이며, 호출 전후에는 - * payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다. + * Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 실행합니다. retry가 허용된 조회성 또는 멱등 Tool에서 일시 장애가 발생하면 설정된 횟수 안에서 재시도하고, 404/410은 retry하지 않고 stale snapshot 보정 refresh만 수행합니다. + * + * @param call Tool 호출 요청입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Tool Service 응답 본문과 총 실행 시간을 반환합니다. */ public Result execute(ToolCall call, McpRequestContext context) { ToolMetadata metadata = registryService.findEnabledTool(context.routeKey(), 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); + int attempt = 1; + while (true) { traceLogger.event( - "tool_http_response_received", + "tool_http_request_started", "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()); - refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception); - throw mapException(exception, toolRequest); + "version", + toolRequest.version(), + "attempt", + attempt, + "maxAttempts", + toolRequest.maxAttempts()); + 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, + "attempt", + attempt); + return new Result(response.data(), duration); + } catch (ToolClientException exception) { + traceLogger.error( + "tool_http_request_failed", + exception, + "toolName", + toolRequest.toolName(), + "attempt", + attempt); + refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception); + if (!shouldRetry(toolRequest, exception, attempt)) { + throw mapException(exception, toolRequest); + } + traceLogger.event( + "tool_http_request_retrying", + "toolName", + toolRequest.toolName(), + "attempt", + attempt + 1, + "backoffMillis", + toolRequest.backoffMillis()); + if (!sleepBeforeRetry(toolRequest.backoffMillis())) { + throw mapException(exception, toolRequest); + } + attempt++; + } } } /** - * Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다. - * 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다. - * route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다. + * Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다. 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다. route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param request 처리할 요청 정보입니다. + * @param exception 처리 중 발생한 예외 정보입니다. */ private void refreshRouteOnStaleToolSignal(String routeKey, ToolRequest request, ToolClientException exception) { if (!isStaleToolSignal(exception) || !claimStaleRefreshSlot(routeKey)) { @@ -111,8 +162,49 @@ public class ToolExecutionService { } /** - * upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다. - * 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다. + * 현재 실패가 retry 가능한 일시 장애인지 판단합니다. 404/410은 stale snapshot 보정 대상이므로 retry에서 제외하고, timeout·network·설정된 HTTP 상태만 retry 대상으로 봅니다. + * + * @param request Tool Service 호출 요청입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @param attempt 현재 시도 번호입니다. + * @return 재시도 여부를 반환합니다. + */ + private boolean shouldRetry(ToolRequest request, ToolClientException exception, int attempt) { + if (!request.retryable() || attempt >= request.maxAttempts() || isStaleToolSignal(exception)) { + return false; + } + if (exception.kind() == ToolClientException.Kind.TIMEOUT + || exception.kind() == ToolClientException.Kind.NETWORK) { + return true; + } + return exception.httpStatusCode().isPresent() + && request.retryOnHttpStatus().contains(exception.httpStatusCode().getAsInt()); + } + + /** + * retry backoff 시간만큼 대기합니다. 요청 thread가 interrupt되면 interrupt 상태를 복구하고 더 이상 재시도하지 않게 false를 반환합니다. + * + * @param backoffMillis 재시도 전 대기 시간입니다. + * @return 다음 retry를 계속해도 되는지 반환합니다. + */ + private boolean sleepBeforeRetry(long backoffMillis) { + if (backoffMillis <= 0) { + return true; + } + try { + Thread.sleep(backoffMillis); + return true; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다. 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isStaleToolSignal(ToolClientException exception) { java.util.OptionalInt status = exception.httpStatusCode(); @@ -120,8 +212,10 @@ public class ToolExecutionService { } /** - * 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다. - * 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다. + * 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다. 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean claimStaleRefreshSlot(String routeKey) { String key = routeKey == null ? "" : routeKey; @@ -136,6 +230,9 @@ public class ToolExecutionService { /** * System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다. + * + * @param startedNanos 계산 또는 제한에 사용할 숫자 값입니다. + * @return 계산된 숫자 값을 반환합니다. */ private double elapsedMillis(long startedNanos) { return (System.nanoTime() - startedNanos) / 1_000_000.0d; @@ -143,6 +240,10 @@ public class ToolExecutionService { /** * Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException mapException(ToolClientException exception, ToolRequest request) { JsonRpcErrorCode code = @@ -150,7 +251,7 @@ public class ToolExecutionService { case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT; case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED; case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN; - case EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR; + case NETWORK, EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR; }; return new JsonRpcException( code, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java index 35a6dc9..b18abdb 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java @@ -1,6 +1,7 @@ package io.shinhanlife.dat.biz.mcp.execute; import io.shinhanlife.dat.biz.mcp.config.McpProperties; +import io.shinhanlife.dat.biz.mcp.config.McpProperties.Retry; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata; @@ -12,8 +13,19 @@ 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.dat.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolRoutingService + * @description Registry에서 확정된 Tool metadata를 실제 {@link io.shinhanlife.dat.biz.mcp.toolclient.ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Service public class ToolRoutingService { @@ -23,14 +35,19 @@ public class ToolRoutingService { /** * Tool별 timeout이 없을 때 사용할 공통 Tool client 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public ToolRoutingService(McpProperties properties) { this.properties = properties; } /** - * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. exact endpoint metadata는 그대로 사용하고, legacy base endpoint metadata만 Tool 이름을 - * path segment로 덧붙입니다. 확정된 endpoint가 절대 HTTP(S)가 아니면 JSON-RPC Tool 실행 오류로 변환합니다. + * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. endpoint와 timeout뿐 아니라 MCP 기본 retry 설정과 Tool annotations를 조합해 이 호출이 재시도 가능한지도 확정합니다. + * + * @param call Tool 호출 요청입니다. + * @param metadata Tool metadata입니다. + * @return Tool Service 호출 요청을 반환합니다. */ public ToolRequest route(ToolCall call, ToolMetadata metadata) { String endpoint = metadata.endpoint(); @@ -43,16 +60,25 @@ public class ToolRoutingService { if (!metadata.exactEndpoint()) { endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name(); } + Retry retry = properties.toolClient().retry(); + boolean retryable = retry.enabled() && metadata.retrySafeByAnnotation() && retry.maxAttempts() > 1; return new ToolRequest( metadata.name(), metadata.version(), endpoint, call.arguments().deepCopy(), - metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis())); + metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis()), + retry.retryOnHttpStatus(), + retry.maxAttempts(), + retry.backoffMillis(), + retryable); } /** * Tool endpoint가 절대 HTTP(S) URL인지 검사해 내부망 상대 경로나 다른 scheme 호출을 막습니다. + * + * @param endpoint 검증할 Tool endpoint입니다. + * @param toolName 대상 Tool 이름입니다. */ private void validateEndpoint(String endpoint, String toolName) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java index 6e052da..55b19a8 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import io.modelcontextprotocol.spec.McpSchema; /** - * 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된 - * 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. 표준 JSON-RPC 숫자는 MCP SDK 상수를 사용하고, Tool 실행·Registry·권한 오류만 이 서버의 확장 범위로 유지합니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcErrorCode + * @description 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public enum JsonRpcErrorCode { PARSE_ERROR(McpSchema.ErrorCodes.PARSE_ERROR, "Parse error"), @@ -22,9 +33,6 @@ public enum JsonRpcErrorCode { private final int code; private final String message; - /** - * 숫자 오류 코드와 외부에 표시할 표준 메시지를 한 쌍으로 저장합니다. - */ JsonRpcErrorCode(int code, String message) { this.code = code; this.message = message; @@ -32,6 +40,8 @@ public enum JsonRpcErrorCode { /** * JSON-RPC error 객체에 기록할 숫자 코드를 반환합니다. + * + * @return 계산된 숫자 값을 반환합니다. */ public int code() { return code; @@ -39,6 +49,8 @@ public enum JsonRpcErrorCode { /** * JSON-RPC error 객체에 기록할 안전한 기본 메시지를 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ public String message() { return message; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java index 68c5850..eff85be 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; /** - * 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. transport, registry, execute 계층이 이 예외를 발생시키고, {@code McpController} 또는 - * {@code McpExceptionHandler}가 JSON-RPC error 응답으로 변환합니다. 주요 의존성은 {@link JsonRpcErrorCode}와 응답 correlation을 위한 JSON 요청 ID이며, HTTP 응답을 직접 만들지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcException + * @description 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public class JsonRpcException extends RuntimeException { @@ -14,6 +25,9 @@ public class JsonRpcException extends RuntimeException { /** * 오류 코드와 간단한 상세 설명만으로 JSON-RPC 예외를 만듭니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param details 처리할 값입니다. */ public JsonRpcException(JsonRpcErrorCode errorCode, String details) { this(errorCode, details, null, null); @@ -21,6 +35,10 @@ public class JsonRpcException extends RuntimeException { /** * 원인 예외를 함께 보존해야 할 때 사용하는 생성자입니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param details 처리할 값입니다. + * @param cause 처리 중 발생한 예외 정보입니다. */ public JsonRpcException(JsonRpcErrorCode errorCode, String details, Throwable cause) { this(errorCode, details, null, cause); @@ -28,6 +46,11 @@ public class JsonRpcException extends RuntimeException { /** * 오류 코드, 응답 data, 원 요청 ID, 원인 예외를 모두 지정합니다. requestId를 보존하면 실패 응답도 어떤 JSON-RPC 요청에서 발생했는지 연결할 수 있습니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param errorData 처리 중 발생한 예외 정보입니다. + * @param requestId 처리할 요청 정보입니다. + * @param cause 처리 중 발생한 예외 정보입니다. */ public JsonRpcException( JsonRpcErrorCode errorCode, Object errorData, JsonNode requestId, Throwable cause) { @@ -39,6 +62,8 @@ public class JsonRpcException extends RuntimeException { /** * 표준 JSON-RPC 오류 종류를 반환합니다. + * + * @return 처리 결과를 반환합니다. */ public JsonRpcErrorCode errorCode() { return errorCode; @@ -46,6 +71,8 @@ public class JsonRpcException extends RuntimeException { /** * 오류 응답의 data 영역에 넣을 안전한 상세 정보를 반환합니다. + * + * @return 처리 결과를 반환합니다. */ public Object errorData() { return errorData; @@ -53,6 +80,8 @@ public class JsonRpcException extends RuntimeException { /** * 실패한 원 요청의 JSON-RPC id를 반환합니다. + * + * @return 처리된 JSON 값을 반환합니다. */ public JsonNode requestId() { return requestId; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java index 71500fa..c5b5be5 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java @@ -4,10 +4,19 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.modelcontextprotocol.spec.McpSchema; /** - * MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다. - * 일반 request handler가 즉시 HTTP 응답으로 반환하는 객체가 아니라 Registry refresh 같은 배경 처리 단계에서 생성되며, - * 실제 전송은 SSE/Streamable HTTP 같은 transport 확장 지점이 담당합니다. 주요 의존성은 JSON-RPC version 상수와 - * notification method 계약입니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcNotification + * @description MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다. + * @author j.h.w + * @create 2026.08.11 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.11 j.h.w 최초생성 + * + **/ @JsonInclude(JsonInclude.Include.NON_NULL) public record JsonRpcNotification(String jsonrpc, String method, Object params) { @@ -15,8 +24,9 @@ public record JsonRpcNotification(String jsonrpc, String method, Object params) public static final String METHOD_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; /** - * Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다. - * notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다. + * Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다. notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다. + * + * @return 처리 결과를 반환합니다. */ public static JsonRpcNotification toolsListChanged() { return new JsonRpcNotification(McpSchema.JSONRPC_VERSION, METHOD_TOOLS_LIST_CHANGED, null); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java index 4f92279..cd9b96b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java @@ -3,13 +3,26 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; /** - * 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증 - * 정보는 포함하지 않고 request context가 별도로 관리합니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcRequest + * @description 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public record JsonRpcRequest(String method, JsonNode params, JsonNode id) { /** * id가 없는 요청인지 확인하여 JSON-RPC notification 여부를 판단합니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean notification() { return id == null || id.isNull(); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java index 9ad7a78..9c9eded 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java @@ -7,14 +7,28 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. 설정된 MCP POST endpoint의 모든 요청이 이 클래스를 지나며 여기서 JSON 구조를 검사합니다. HTTP 경계 로그는 filter가 - * 담당하므로 이 parser는 별도 trace logger를 사용하지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcRequestParser + * @description HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class JsonRpcRequestParser { /** * HTTP 본문에서 읽은 JSON 객체를 서버 내부의 {@link JsonRpcRequest}로 변환합니다. envelope를 먼저 검증하며 params가 없으면 비어 있는 JSON 객체를 사용합니다. 지원 method 여부는 handler registry가 확인합니다. + * + * @param envelope 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ public JsonRpcRequest parse(JsonNode envelope) { try { @@ -33,6 +47,8 @@ public class JsonRpcRequestParser { /** * JSON-RPC 2.0 요청 envelope의 필수 구조와 타입을 검사합니다. + * + * @param envelope 처리할 요청 정보입니다. */ private void validate(JsonNode envelope) { if (envelope == null || !envelope.isObject()) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java index 8954cd7..8a7bcec 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java @@ -8,14 +8,29 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. {@code McpController}와 {@code McpExceptionHandler}가 설정된 MCP endpoint의 응답 본문으로 사용하며, 성공과 - * 오류를 동시에 넣지 않습니다. 주요 의존성은 request ID correlation을 위한 {@link JsonNode}와 null 필드를 제외하는 Jackson 직렬화 설정입니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcResponse + * @description MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @JsonInclude(JsonInclude.Include.NON_NULL) public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNode id) { /** * 정상 처리 결과와 원 요청 ID를 JSON-RPC 2.0 성공 응답으로 감쌉니다. + * + * @param id 입력값입니다. + * @param result 입력값입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse success(JsonNode id, Object result) { return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, result, null, id); @@ -23,6 +38,10 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo /** * 표준 오류 정보와 원 요청 ID를 JSON-RPC 2.0 실패 응답으로 감쌉니다. + * + * @param id 입력값입니다. + * @param error 처리 중 발생한 예외 정보입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse failure(JsonNode id, Error error) { return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, null, error, id); @@ -30,6 +49,11 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo /** * 내부 오류 코드와 상세 정보를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다. + * + * @param id 입력값입니다. + * @param code 입력값입니다. + * @param details 처리할 값입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse failure(JsonNode id, JsonRpcErrorCode code, Object details) { Map
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class InitializeHandler implements McpMethodHandlerRegistry.Handler { @@ -18,6 +29,8 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public InitializeHandler(McpProperties properties) { this.properties = properties; @@ -25,6 +38,8 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 MCP method 이름인 `initialize`를 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -33,6 +48,10 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * Agent Builder에 protocol version, 서버 정보, 지원 capability를 알려 주는 initialize 결과를 만듭니다. 요청 ID를 그대로 응답에 넣어 JSON-RPC correlation을 유지합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java index 0181807..52dcd74 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java @@ -10,14 +10,28 @@ import java.util.Map; import org.springframework.stereotype.Component; /** - * AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. 이 요청은 서버 상태나 세션을 만들지 않고 {@code McpController}가 HTTP 202으로 - * 마무리합니다. 별도 협력 객체 없이 표준 notification acknowledgement만 반환합니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className InitializedNotificationHandler + * @description AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class InitializedNotificationHandler implements McpMethodHandlerRegistry.Handler { /** - * 이 handler가 담당하는 `notifications/initialized` method 이름을 반환합니다. + * 이 handler가 담당하는 +otifications/initialized` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -26,6 +40,10 @@ public class InitializedNotificationHandler implements McpMethodHandlerRegistry. /** * Agent Builder의 initialize 완료 notification을 수용합니다. 서버 상태를 생성하지 않으며 {@code McpController}가 HTTP 202 빈 응답으로 최종 처리합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java index 3be21b6..08571b6 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java @@ -13,8 +13,19 @@ 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 오류 모델입니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className McpMethodHandlerRegistry + * @description Spring이 만든 MCP method handler를 method 문자열 기준으로 인덱싱하고, {@code McpController}의 명시적 dispatch를 지원합니다. 설정된 MCP endpoint 요청은 transport 검증 후 이 registry에서 initialize·tools/list·tools/call handler를 찾아 처리합니다. 주요 의존성은 {@link Handler} 구현체 목록과 지원하지 않는 method를 거절하는 JSON-RPC 오류 모델입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class McpMethodHandlerRegistry { @@ -23,6 +34,8 @@ public class McpMethodHandlerRegistry { /** * Spring이 찾은 모든 handler를 method 이름 기준의 읽기 전용 map으로 구성합니다. 같은 method를 담당하는 handler가 둘이면 시작 시 즉시 실패해 모호한 dispatch를 막습니다. + * + * @param handlers 처리 대상 목록입니다. */ public McpMethodHandlerRegistry(List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { @@ -25,6 +36,8 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실행 서비스를 주입받습니다. + * + * @param executionService 협력 객체입니다. */ public ToolsCallHandler(ToolExecutionService executionService) { this.executionService = executionService; @@ -32,6 +45,8 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 `tools/call` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -40,6 +55,10 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 일반 tools/call 요청에서 명시된 단일 Tool을 실행합니다. Tool 실행 계열 오류는 MCP 규칙에 맞춰 최상위 error가 아닌 `isError=true` result로 변환합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { @@ -57,6 +76,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 표준 tools/call params에서 Tool 이름과 object arguments를 검증해 내부 호출 값으로 만듭니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ private ToolCall extract(JsonRpcRequest request) { String toolName = request.params().path("name").asText(null); @@ -72,6 +94,10 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 요청 ID를 보존한 Invalid params 예외를 만듭니다. + * + * @param request 처리할 요청 정보입니다. + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException invalid(JsonRpcRequest request, String details) { return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details, request.id(), null); @@ -79,6 +105,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실행 결과를 text content와 실행 시간 meta를 가진 MCP 성공 결과로 변환합니다. + * + * @param result 입력값입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.CallToolResult successResult(ToolExecutionService.Result result) { McpSchema.TextContent content = @@ -90,6 +119,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 응답을 MCP text content에 넣을 문자열로 바꾸며 JSON 객체와 배열은 compact JSON을 유지합니다. + * + * @param data 입력값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String asText(JsonNode data) { if (data == null || data.isNull()) { @@ -100,6 +132,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실패 상세를 사용자에게 전달 가능한 text content와 `isError=true` 결과로 변환합니다. + * + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.CallToolResult failureResult(Object details) { McpSchema.TextContent content = McpSchema.TextContent.builder(String.valueOf(details)).build(); @@ -108,6 +143,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * JSON-RPC envelope 오류가 아니라 MCP Tool result로 표현해야 하는 실행 계열 오류인지 구분합니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isToolExecutionFailure(JsonRpcErrorCode errorCode) { return errorCode == JsonRpcErrorCode.TOOL_EXECUTION_ERROR diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java index d3e58b4..78ae7e2 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java @@ -13,9 +13,19 @@ import java.util.List; import org.springframework.stereotype.Component; /** - * MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. 내부 Tool Registry의 활성 metadata를 읽어 MCP SDK의 표준 {@link McpSchema.Tool}과 - * {@link McpSchema.ListToolsResult}로 변환합니다. 주요 의존성은 캐시 및 원천 조회를 감싸는 {@link io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local - * catalog의 공개 필드만 SDK 모델로 옮깁니다. endpoint·timeout 등 실행용 운영 정보는 응답에 노출하지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className ToolsListHandler + * @description MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { @@ -25,6 +35,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 활성 Tool metadata를 조회할 Registry service와 SDK 모델 변환용 Jackson mapper를 주입받습니다. + * + * @param registryService 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. */ public ToolsListHandler(ToolRegistryService registryService, ObjectMapper objectMapper) { this.registryService = registryService; @@ -33,6 +46,8 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 `tools/list` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -41,6 +56,10 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 실행용 metadata에서 외부 공개 필드만 골라 MCP tools/list 응답을 만듭니다. 내부 endpoint나 timeout 정보는 Agent Builder 응답에 노출하지 않습니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { @@ -49,8 +68,10 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { } /** - * 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은 - * {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다. + * 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은 {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다. + * + * @param metadata Tool 처리 정보입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.Tool toMcpTool(ToolMetadata metadata) { if (metadata.publicDefinition() != null) { @@ -64,6 +85,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 공개 Tool 정의를 복사해 실행용 {@code _meta}만 제거합니다. 원본 snapshot은 바꾸지 않습니다. + * + * @param definition 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode withoutExecutionMetadata(JsonNode definition) { if (!definition.isObject() || !definition.has("_meta")) { @@ -76,6 +100,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 기존 Registry 응답에 inputSchema가 없으면 SDK 필수 조건을 만족하는 빈 object schema로 정규화합니다. schema가 있으면 field를 변경하지 않고 Jackson Map으로 옮깁니다. + * + * @param metadata Tool 처리 정보입니다. + * @return 처리 결과를 반환합니다. */ @SuppressWarnings("unchecked") private java.util.Map
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component @Endpoint(id = "toolBundles") @@ -26,6 +37,8 @@ public class ToolBundleStatusEndpoint { /** * 운영 조회 시 사용할 bundle discovery 상태 저장소를 주입받습니다. + * + * @param discovery 협력 객체입니다. */ public ToolBundleStatusEndpoint(ToolBundleDiscovery discovery) { this.discovery = discovery; @@ -33,6 +46,8 @@ public class ToolBundleStatusEndpoint { /** * 선언된 모든 bundle의 현재 상태를 읽기 전용 Map으로 반환합니다. 상태 조회는 manifest refresh나 Tool 실행을 유발하지 않습니다. + * + * @return Tool Service bundle 상태 목록을 반환합니다. */ @ReadOperation public Map
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class ToolCatalogHealthIndicator implements HealthIndicator { @@ -19,6 +29,9 @@ public class ToolCatalogHealthIndicator implements HealthIndicator { /** * 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다. + * + * @param scheduler 협력 객체입니다. + * @param registryService 협력 객체입니다. */ public ToolCatalogHealthIndicator( ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) { @@ -28,6 +41,8 @@ public class ToolCatalogHealthIndicator implements HealthIndicator { /** * 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다. + * + * @return 처리 결과를 반환합니다. */ @Override public Health health() { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java index 4d75658..3022225 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java @@ -9,9 +9,19 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; /** - * MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다. - * 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다. - * payload 본문은 이 공통 logger가 보관하지 않고, 임시 검증이 필요한 경계 클래스에서 삭제하기 쉬운 별도 {@code TEMP_*} 로그로만 남깁니다. + * @package io.shinhanlife.dat.biz.mcp.observability + * @className TraceLogger + * @description TraceLogger 구성요소입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class TraceLogger { @@ -21,14 +31,18 @@ public class TraceLogger { /** * trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public TraceLogger(McpProperties properties) { this.properties = properties; } /** - * 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. - * trace 로그 설정이 켜진 경우에만 기록하며, 요청·응답 본문은 포함하지 않습니다. + * 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. trace 로그 설정이 켜진 경우에만 기록하며, 요청·응답 본문은 포함하지 않습니다. + * + * @param event 처리할 값입니다. + * @param keyValues 처리 대상 목록입니다. */ public void event(String event, Object... keyValues) { if (properties.trace().enabled()) { @@ -43,8 +57,11 @@ public class TraceLogger { } /** - * 예외가 발생한 처리 단계를 오류 로그로 남깁니다. - * 오류 로그에는 payload를 포함하지 않고 예외 종류와 메시지만 correlation 값과 함께 남깁니다. + * 예외가 발생한 처리 단계를 오류 로그로 남깁니다. 오류 로그에는 payload를 포함하지 않고 예외 종류와 메시지만 correlation 값과 함께 남깁니다. + * + * @param event 처리할 값입니다. + * @param error 처리 중 발생한 예외 정보입니다. + * @param keyValues 처리 대상 목록입니다. */ public void error(String event, Throwable error, Object... keyValues) { McpRequestContext context = McpRequestContextHolder.get().orElse(null); @@ -60,8 +77,10 @@ public class TraceLogger { } /** - * 가변 인자로 받은 key/value 쌍을 사람이 읽기 쉬운 key=value 문자열로 변환합니다. - * 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다. + * 가변 인자로 받은 key/value 쌍을 사람이 읽기 쉬운 key=value 문자열로 변환합니다. 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다. + * + * @param keyValues 처리 대상 목록입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String fields(Object... keyValues) { StringJoiner joiner = new StringJoiner(" "); @@ -73,6 +92,9 @@ public class TraceLogger { /** * 줄바꿈과 공백을 치환해 로그 이벤트가 여러 줄로 갈라지지 않게 합니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String safe(Object value) { if (value == null) { @@ -83,6 +105,9 @@ public class TraceLogger { /** * 요청 context가 있을 때 end-to-end 상관 값 guid를 반환하고, background 로그에는 빈 값을 사용합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String guid(McpRequestContext context) { return context == null ? "" : safe(context.guid()); @@ -90,6 +115,9 @@ public class TraceLogger { /** * 요청 context가 있을 때 개별 HTTP request ID를 반환하고, background 로그에는 빈 값을 사용합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String requestId(McpRequestContext context) { return context == null ? "" : safe(context.requestId()); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java index 369c4eb..06a864e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -15,12 +15,23 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; -/** - * 매니페스트 조회를 끈 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 + +/** + * @package io.shinhanlife.dat.biz.mcp.registry + * @className LocalFileToolRegistryClient + * @description 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + *+ */ @Profile("local") @ConditionalOnExpression("!${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") public class LocalFileToolRegistryClient implements ToolRegistryClient { @@ -31,6 +42,10 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local Tool catalog의 resource loader, JSON mapper, 파일 위치 설정을 주입받습니다. + * + * @param resourceLoader 처리할 값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public LocalFileToolRegistryClient( ResourceLoader resourceLoader, ObjectMapper objectMapper, McpProperties properties) { @@ -41,6 +56,9 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.11 j.h.w 최초생성 + * + **/ @Component @ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "true") @@ -51,9 +59,14 @@ public class PortalToolRegistryClient implements ToolRegistryClient { new java.util.concurrent.ConcurrentHashMap<>(); /** - * Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다. - * registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어 - * 동일한 endpoint snapshot 변환 경로에 전달합니다. + * Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다. registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어 동일한 endpoint snapshot 변환 경로에 전달합니다. + * + * @param restClient 협력 객체입니다. + * @param properties MCP 설정 정보입니다. + * @param discovery 협력 객체입니다. + * @param redisPortalRegistryCache 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param resourceLoader 처리할 값입니다. */ public PortalToolRegistryClient( @Qualifier("manifestRestClient") RestClient restClient, @@ -71,8 +84,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다. - * 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다. + * 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다. 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.12 j.h.w 최초생성 + * + **/ @Component @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") @@ -28,8 +38,11 @@ public class RedisPortalRegistryCache { private final String cacheKey; /** - * Redis 접근 객체와 JSON mapper, MCP 설정에서 포털 registry fallback key를 구성합니다. - * Redis가 꺼져 있으면 Spring 조건에 의해 생성되지 않으며, key 값은 운영에서 포털과 합의한 값으로 덮어씁니다. + * Redis 접근 객체와 JSON mapper, MCP 설정에서 포털 registry fallback key를 구성합니다. Redis가 꺼져 있으면 Spring 조건에 의해 생성되지 않으며, key 값은 운영에서 포털과 합의한 값으로 덮어씁니다. + * + * @param redisTemplate 입력값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public RedisPortalRegistryCache( StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { @@ -39,8 +52,9 @@ public class RedisPortalRegistryCache { } /** - * 포털이 Redis에 저장한 aggregate registry JSON을 읽습니다. - * key miss, Redis 장애, JSON 파싱 오류는 모두 cache miss로 처리해 포털 API나 memory snapshot의 정상 동작을 막지 않습니다. + * 포털이 Redis에 저장한 aggregate registry JSON을 읽습니다. key miss, Redis 장애, JSON 파싱 오류는 모두 cache miss로 처리해 포털 API나 memory snapshot의 정상 동작을 막지 않습니다. + * + * @return 조회된 선택값을 반환합니다. */ public Optional
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") public class RedisToolRegistryCache { - /** - * route별 key 구조를 포함하는 Tool snapshot cache schema version입니다. - * 기존 단일 {@code :all} key와 섞이지 않도록 version을 올려 서로 다른 route의 Tool 목록이 같은 key를 공유하지 않게 합니다. - */ static final String CACHE_SCHEMA_VERSION = "v2"; private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class); @@ -39,8 +45,11 @@ public class RedisToolRegistryCache { private final Duration ttl; /** - * Redis 접근, JSON 변환, route별 key prefix와 TTL 설정을 주입받아 공유 cache를 구성합니다. - * 이 생성자는 외부 요청을 처리하지 않고, 이후 route별 load/save 호출에서 key를 완성합니다. + * Redis 접근, JSON 변환, route별 key prefix와 TTL 설정을 주입받아 공유 cache를 구성합니다. 이 생성자는 외부 요청을 처리하지 않고, 이후 route별 load/save 호출에서 key를 완성합니다. + * + * @param redisTemplate 입력값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public RedisToolRegistryCache( StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { @@ -53,32 +62,38 @@ public class RedisToolRegistryCache { } /** - * 기존 단일 route 호출부와 테스트가 사용하는 기본 route Redis key를 반환합니다. - * 실제 route별 진단에는 {@link #key(String)}를 사용합니다. + * 기존 단일 route 호출부와 테스트가 사용하는 기본 route Redis key를 반환합니다. 실제 route별 진단에는 {@link #key(String)}를 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ public String key() { return key(""); } /** - * 지정 route가 사용하는 Redis key를 반환합니다. - * route 원문은 key 구분자와 충돌하지 않도록 URL-safe Base64 token으로 변환합니다. + * 지정 route가 사용하는 Redis key를 반환합니다. route 원문은 key 구분자와 충돌하지 않도록 URL-safe Base64 token으로 변환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ public String key(String routeKey) { return cacheKeyPrefix + ":" + routeToken(routeKey); } /** - * 기본 route의 Tool snapshot을 읽습니다. - * route별 호출부는 {@link #loadSnapshot(String)}를 사용해 다른 route와 cache가 섞이지 않게 합니다. + * 기본 route의 Tool snapshot을 읽습니다. route별 호출부는 {@link #loadSnapshot(String)}를 사용해 다른 route와 cache가 섞이지 않게 합니다. + * + * @return 조회된 선택값을 반환합니다. */ public Optional
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component @ConditionalOnExpression("${mcp.discovery.enabled:false} || ${mcp.portal.enabled:false}") @@ -51,6 +61,10 @@ public class ToolBundleDiscovery { /** * bundle 매니페스트 조회용 RestClient, JSON mapper, 조회 정책 설정을 주입받습니다. local fallback 파일은 Spring ResourceLoader로 읽어 file:과 classpath: 위치를 모두 지원합니다. + * + * @param restClient 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public ToolBundleDiscovery( @Qualifier("manifestRestClient") RestClient restClient, @@ -64,14 +78,18 @@ public class ToolBundleDiscovery { /** * 활성 bundle 전체를 동시에 조회해 bundle별 결과를 반환합니다. 순차 조회는 소요 시간이 합산되어 기동과 갱신을 지연시키므로 virtual thread로 병렬 조회하며, 각 작업이 자기 예외를 결과값으로 변환하므로 이 method는 예외를 던지지 않습니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component @ConditionalOnExpression("${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") @@ -28,6 +38,9 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { /** * bundle 조회 구성요소와 병합 상한 설정을 주입받습니다. + * + * @param discovery 협력 객체입니다. + * @param properties MCP 설정 정보입니다. */ public ToolBundleRegistryClient(ToolBundleDiscovery discovery, McpProperties properties) { this.discovery = discovery; @@ -35,9 +48,10 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { } /** - * 활성 bundle을 모두 조회한 뒤 사용할 수 있는 Tool snapshot만 병합해 반환합니다. - * 일부 bundle이 아직 한 번도 성공하지 못했어도 다른 bundle의 논리 MCP 동작을 막지 않으며, 모든 bundle이 사용할 수 없을 때만 Registry unavailable을 던져 - * {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. + * 활성 bundle을 모두 조회한 뒤 사용할 수 있는 Tool snapshot만 병합해 반환합니다. 일부 bundle이 아직 한 번도 성공하지 못했어도 다른 bundle의 논리 MCP 동작을 막지 않으며, 모든 bundle이 사용할 수 없을 때만 Registry unavailable을 던져 {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.11 j.h.w 최초생성 + * + **/ public record ToolListChangedEvent(String routeKey, JsonRpcNotification notification) { - /** - * 변경된 route key와 표준 {@code notifications/tools/list_changed} envelope를 묶습니다. - * route key는 전송 계층이 같은 route로 initialize한 Agent Builder 연결만 골라 알릴 때 사용합니다. - */ public ToolListChangedEvent { } /** - * route별 Tool 목록 변경 이벤트를 생성합니다. - * notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다. + * route별 Tool 목록 변경 이벤트를 생성합니다. notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public static ToolListChangedEvent forRoute(String routeKey) { return new ToolListChangedEvent(routeKey, JsonRpcNotification.toolsListChanged()); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java index 3959739..8b02f88 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java @@ -4,8 +4,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; /** - * 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고, - * {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolMetadata + * @description 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @JsonIgnoreProperties(ignoreUnknown = true) public record ToolMetadata( @@ -19,6 +30,18 @@ public record ToolMetadata( JsonNode publicDefinition, boolean exactEndpoint) { + /** + * 입력값을 내부 처리 형식으로 변환합니다. + * + * @param name 대상 이름입니다. + * @param version Tool 버전입니다. + * @param description Tool 설명입니다. + * @param endpoint 실행 endpoint입니다. + * @param inputSchema 입력 JSON Schema입니다. + * @param timeoutMillis Tool timeout 밀리초입니다. + * @param enabled 활성화 여부입니다. + * @param publicDefinition tools/list에 노출할 공개 정의입니다. + */ public ToolMetadata( String name, String version, @@ -33,8 +56,27 @@ public record ToolMetadata( /** * Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다. + * + * @param defaultTimeoutMillis 기본 timeout 밀리초입니다. + * @return 실제 Tool 호출에 사용할 timeout 밀리초를 반환합니다. */ public int effectiveTimeoutMillis(int defaultTimeoutMillis) { return timeoutMillis == null || timeoutMillis <= 0 ? defaultTimeoutMillis : timeoutMillis; } + + /** + * Tool 공개 정의의 annotations를 기준으로 MCP 기본 retry가 가능한지 판단합니다. 조회 전용이거나 멱등 Tool이면 허용하고, 파괴적 작업으로 표시된 Tool은 항상 금지합니다. + * + * @return MCP 기본 retry 허용 여부를 반환합니다. + */ + public boolean retrySafeByAnnotation() { + JsonNode annotations = publicDefinition == null ? null : publicDefinition.path("annotations"); + if (annotations == null || annotations.isMissingNode() || annotations.isNull()) { + return false; + } + boolean destructive = annotations.path("destructiveHint").asBoolean(false); + boolean readOnly = annotations.path("readOnlyHint").asBoolean(false); + boolean idempotent = annotations.path("idempotentHint").asBoolean(false); + return !destructive && (readOnly || idempotent); + } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java index d5bbc5c..d5a7555 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java @@ -4,40 +4,53 @@ import java.util.List; import java.util.Map; /** - * Tool metadata의 원천(source)을 읽는 역할입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolRegistryClient + * @description Tool metadata의 원천(source)을 읽는 역할입니다. + * @author j.h.w + * @create 2026.08.06 * - *
이 interface는 cache가 아닙니다. {@link ToolRegistryService}가 요청 경로에서는 memory snapshot을 읽고, cold - * start 또는 배경 refresh 때만 구현체를 호출합니다. local profile은 JSON 파일을, 운영 profile은 설정된 Tool Service bundle의 매니페스트 aggregate를 원천으로 사용합니다. Redis는 원천이 아니라 기동 warm start와 - * 성공 snapshot 공유에만 쓰는 선택적 cache입니다. + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 * - **/ public interface ToolRegistryClient { /** * 현재 profile의 원천에서 Tool 전체 목록을 읽어 immutable 목록으로 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ List직접 MCP 요청을 처리하지 않는 outbound port이며, local 파일 구현과 운영 HTTP 구현을 profile에 따라 교체합니다. + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class ToolRegistryRefreshScheduler { @@ -22,14 +33,17 @@ public class ToolRegistryRefreshScheduler { /** * Registry refresh를 실행할 service를 주입받습니다. + * + * @param registryService 협력 객체입니다. */ public ToolRegistryRefreshScheduler(ToolRegistryService registryService) { this.registryService = registryService; } /** - * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 - * 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + * + * @return 처리 결과를 반환합니다. */ @EventListener(ApplicationReadyEvent.class) public void preload() { @@ -40,8 +54,9 @@ public class ToolRegistryRefreshScheduler { } /** - * 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면 - * 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다. + * 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean firstAttemptCompleted() { return firstAttemptCompleted; @@ -61,6 +76,8 @@ public class ToolRegistryRefreshScheduler { /** * 설정된 간격마다 Tool Service manifest를 다시 읽어 cache snapshot을 갱신합니다. 첫 scheduled 실행에는 bounded random jitter를 더해 동시에 기동한 replica의 조회 시점을 분산합니다. + * + * @return 처리 결과를 반환합니다. */ @Scheduled( fixedDelayString = "${mcp.registry.refresh-interval-seconds:30}", @@ -74,8 +91,9 @@ public class ToolRegistryRefreshScheduler { } /** - * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. - * manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. + * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. + * + * @return 처리 결과를 반환합니다. */ @Scheduled( fixedDelayString = "${mcp.portal.refresh-interval-seconds:300}", @@ -89,6 +107,8 @@ public class ToolRegistryRefreshScheduler { /** * refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다. + * + * @param trigger 입력값입니다. */ private void safeManifestRefresh(String trigger) { try { @@ -105,6 +125,9 @@ public class ToolRegistryRefreshScheduler { /** * 포털 registry endpoint 목록 갱신 실패를 로그로 격리하여 manifest refresh와 요청 경로에 영향을 주지 않게 합니다. + * + * @param trigger 입력값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean safePortalRefresh(String trigger) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java index ceee42c..9b4a5d1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java @@ -21,9 +21,19 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; /** - * Tool Registry metadata 조회를 담당하는 서비스입니다. {@code tools/list}와 {@code tools/call} 요청 경로는 - * route별 in-memory snapshot만 읽고, 포털/Tool Service/Redis 조회는 기동 직후 또는 배경 갱신 경로에서만 수행합니다. - * 주요 협력 객체는 원천 조회 port인 {@link ToolRegistryClient}, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolRegistryService + * @description Tool Registry metadata 조회를 담당하는 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Service public class ToolRegistryService implements McpRouteKeyValidator { @@ -39,18 +49,22 @@ public class ToolRegistryService implements McpRouteKeyValidator { new ConcurrentHashMap<>(); /** - * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다. - * 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다. + * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다. 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다. + * + * @param registryClient 협력 객체입니다. + * @param redisCache 협력 객체입니다. */ public ToolRegistryService( ToolRegistryClient registryClient, Optional
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class HttpToolClient implements ToolClient { @@ -43,8 +53,11 @@ public class HttpToolClient implements ToolClient { private final HttpClient toolHttpClient; /** - * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. - * 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다. + * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다. + * + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param toolHttpClient Tool 처리 정보입니다. */ public HttpToolClient( ObjectMapper objectMapper, @@ -56,8 +69,11 @@ public class HttpToolClient implements ToolClient { } /** - * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. - * Tool 호출용 request ID를 새로 발급하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다. + * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. Tool 호출용 request ID를 새로 발급하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 처리 결과를 반환합니다. */ @Override public ToolResponse execute(ToolRequest request, McpRequestContext context) { @@ -92,15 +108,18 @@ public class HttpToolClient implements ToolClient { throw new ToolClientException( ToolClientException.Kind.TIMEOUT, "Tool timed out: " + request.toolName(), exception); } - throw executionException(request, exception); + throw new ToolClientException( + ToolClientException.Kind.NETWORK, "Tool network error: " + request.toolName(), exception); } catch (RestClientException | IllegalArgumentException exception) { throw executionException(request, exception); } } /** - * 임시 Tool 본문 로그를 보기 쉽게 출력하기 위해 JSON 값을 개행된 문자열로 변환합니다. - * 직렬화 실패 시에도 Tool 호출 결과는 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * 임시 Tool 본문 로그를 보기 쉽게 출력하기 위해 JSON 값을 개행된 문자열로 변환합니다. 직렬화 실패 시에도 Tool 호출 결과는 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String prettyJson(JsonNode value) { try { @@ -111,8 +130,11 @@ public class HttpToolClient implements ToolClient { } /** - * URI, 표준 추적 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. - * end-to-end 값인 {@code X-Guid}는 이어가고, Tool Service 요청의 단건 식별자인 {@code X-Request-Id}와 {@code X-Request-Time}은 여기서 새로 발급합니다. + * URI, 표준 추적 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. end-to-end 값인 {@code X-Guid}는 이어가고, Tool Service 요청의 단건 식별자인 {@code X-Request-Id}와 {@code X-Request-Time}은 여기서 새로 발급합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 처리 결과를 반환합니다. */ private RestClient.RequestBodySpec requestSpec(ToolRequest request, McpRequestContext context) { RestClient client = clientFor(remainingTimeoutMillis(request, context)); @@ -147,6 +169,9 @@ public class HttpToolClient implements ToolClient { /** * 공유 JDK HttpClient 위에 이번 호출의 read timeout만 적용한 경량 RestClient를 만듭니다. + * + * @param readTimeoutMillis 계산 또는 제한에 사용할 숫자 값입니다. + * @return 처리 결과를 반환합니다. */ private RestClient clientFor(int readTimeoutMillis) { JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(toolHttpClient); @@ -156,6 +181,10 @@ public class HttpToolClient implements ToolClient { /** * Tool timeout과 전체 MCP deadline 중 더 짧은 남은 시간을 실제 read timeout으로 선택합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 계산된 숫자 값을 반환합니다. */ private int remainingTimeoutMillis(ToolRequest request, McpRequestContext context) { long remainingMillis = context.remainingMillis(); @@ -169,8 +198,11 @@ public class HttpToolClient implements ToolClient { } /** - * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다. - * Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다. + * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 "null"이 전달되지 않게 합니다. Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다. + * + * @param headers 처리할 값입니다. + * @param name 대상 이름입니다. + * @param value 처리할 값입니다. */ private void set(org.springframework.http.HttpHeaders headers, String name, String value) { if (value != null) { @@ -179,8 +211,9 @@ public class HttpToolClient implements ToolClient { } /** - * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다. - * OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다. + * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다. OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String localHostAddress() { try { @@ -191,8 +224,9 @@ public class HttpToolClient implements ToolClient { } /** - * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다. - * 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다. + * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다. 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String localHostName() { try { @@ -204,6 +238,10 @@ public class HttpToolClient implements ToolClient { /** * upstream HTTP 상태를 권한 오류 또는 일반 실행 오류 ToolClientException으로 변환합니다. + * + * @param status 계산 또는 제한에 사용할 숫자 값입니다. + * @param toolName 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ private ToolClientException statusException(int status, String toolName) { ToolClientException.Kind kind = @@ -217,6 +255,10 @@ public class HttpToolClient implements ToolClient { /** * 네트워크·직렬화 등 일반 client 예외를 Tool 이름이 포함된 실행 실패로 감쌉니다. + * + * @param request 처리할 요청 정보입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ private ToolClientException executionException(ToolRequest request, Exception exception) { return new ToolClientException( @@ -225,6 +267,9 @@ public class HttpToolClient implements ToolClient { /** * 예외 cause chain 전체를 따라가며 실제 socket timeout이 포함되어 있는지 확인합니다. + * + * @param throwable 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean hasTimeoutCause(Throwable throwable) { Throwable current = throwable; @@ -238,8 +283,11 @@ public class HttpToolClient implements ToolClient { } /** - * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. - * JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다. + * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다. + * + * @param body 처리할 값입니다. + * @param contentType 입력값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode parseResponse(String body, MediaType contentType) { if (body == null) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java index f47e37e..e23940e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java @@ -2,23 +2,70 @@ package io.shinhanlife.dat.biz.mcp.toolclient; import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; +import java.util.List; /** - * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해 - * 반환합니다. + * @package io.shinhanlife.dat.biz.mcp.toolclient + * @className ToolClient + * @description 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ public interface ToolClient { /** * ToolRequest를 한 번 실행하고 HTTP 상태와 응답 data를 반환하는 기본 Tool 호출 port입니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Tool Service 호출 결과를 반환합니다. */ ToolResponse execute(ToolRequest request, McpRequestContext context); /** - * Tool Service로 전달할 URL, arguments, timeout을 묶는 불변 요청 값 객체입니다. + * Tool Service로 전달할 URL, arguments, timeout, retry 판단 결과를 묶는 불변 요청 값 객체입니다. */ record ToolRequest( - String toolName, String version, String endpoint, JsonNode arguments, int timeoutMillis) { + String toolName, + String version, + String endpoint, + JsonNode arguments, + int timeoutMillis, + List
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { - /** - * 읽어 둔 요청 본문. 이 클래스 밖으로 배열 자체를 넘기지 않고 스트림으로만 노출합니다. - */ private final byte[] body; - /** - * 원본 요청 본문을 설정된 최대 크기까지만 메모리에 읽어 둡니다. - * - *
한도보다 1 byte 더 읽는 이유는, 전체를 다 읽어 본 뒤에 크기를 재면 거대한 요청이 이미 메모리에 올라온 뒤이기 때문입니다. 한도+1을 읽어 그 - * 길이가 한도를 넘으면 나머지는 읽지 않고 바로 차단합니다. - * - * @param maxBodyBytes 허용할 본문 최대 byte 수 - * @throws RequestBodyTooLargeException 본문이 한도를 넘어 controller까지 보내지 않고 끊을 때 - */ CachedBodyHttpServletRequest(HttpServletRequest request, int maxBodyBytes) throws IOException { super(request); byte[] candidate = request.getInputStream().readNBytes(maxBodyBytes + 1); @@ -42,37 +41,51 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { } /** - * 요청 본문을 읽을 수 있는 스트림을 매번 새로 만들어 돌려줍니다. + * 요청 본문을 읽을 수 있는 스트림을 매번 새로 만들어 돌려줍니다.
이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 한 번 읽으면 끝입니다. 그런데 이 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면 두 번 읽어도 문제가 없습니다. * - *
이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 한 번 읽으면 끝입니다. 그런데 이 - * 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면 - * 두 번 읽어도 문제가 없습니다. + * @return 처리 결과를 반환합니다. */ @Override public ServletInputStream getInputStream() { ByteArrayInputStream input = new ByteArrayInputStream(body); return new ServletInputStream() { /** 한 byte를 읽어 반환합니다. 더 읽을 것이 없으면 {@code -1}입니다. */ + /** + * 필요한 정보를 조회합니다. + * + * @return 계산된 숫자 값을 반환합니다. + */ @Override public int read() { return input.read(); } /** 본문을 끝까지 읽었는지 알려 줍니다. 메모리 배열이라 남은 byte 수로 바로 판단합니다. */ + /** + * 조건 충족 여부를 확인합니다. + * + * @return 조건 충족 여부를 반환합니다. + */ @Override public boolean isFinished() { return input.available() == 0; } /** 지금 바로 읽어도 되는지 알려 줍니다. 네트워크가 아니라 이미 메모리에 있는 데이터이므로 기다릴 일이 없어 항상 {@code true}입니다. */ + /** + * 조건 충족 여부를 확인합니다. + * + * @return 조건 충족 여부를 반환합니다. + */ @Override public boolean isReady() { return true; } /** - * 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를 - * 던져 잘못된 사용을 드러냅니다. + * 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를 던져 잘못된 사용을 드러냅니다. + * + * @param readListener 입력값입니다. */ @Override public void setReadListener(ReadListener readListener) { @@ -82,17 +95,20 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { } /** - * 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다. - */ - /** - * 캐시된 요청 본문을 payload 로그용 문자열로 반환합니다. - * 이미 메모리에 보관된 byte 배열만 읽으므로 controller가 다시 본문을 읽는 흐름에는 영향을 주지 않습니다. + * 캐시된 요청 본문을 payload 로그용 문자열로 반환합니다. 이미 메모리에 보관된 byte 배열만 읽으므로 controller가 다시 본문을 읽는 흐름에는 영향을 주지 않습니다. + * + * @return 처리된 문자열 값을 반환합니다. */ String bodyText() { String encoding = getCharacterEncoding(); Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding); return new String(body, charset); } + /** + * 필요한 정보를 조회합니다. + * + * @return 처리 결과를 반환합니다. + */ @Override public BufferedReader getReader() { String encoding = getCharacterEncoding(); @@ -100,14 +116,12 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { return new BufferedReader(new InputStreamReader(getInputStream(), charset)); } - /** - * 요청 본문이 설정된 한도를 넘었음을 알리는 내부 전용 예외입니다. {@link McpExchangeFilter}가 이 예외를 잡아 JSON-RPC {@code -32600 Invalid Request}로 바꾸며, controller까지 요청이 전달되지 않습니다. 이 클래스 - * 밖에서는 만들 수 없습니다. - */ static final class RequestBodyTooLargeException extends IOException { /** * 한도 값을 메시지에 담아, 로그만 보고도 어떤 설정 때문에 막혔는지 알 수 있게 합니다. + * + * @param maxBodyBytes 처리할 값입니다. */ private RequestBodyTooLargeException(int maxBodyBytes) { super("MCP request body exceeds configured maximum of " + maxBodyBytes + " bytes"); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java index bc6cd1e..6401689 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java @@ -21,8 +21,19 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** - * 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. JSON-RPC 요청을 parser로 검증하고 handler로 dispatch하며, notification HTTP 202, initialize 세션 correlation - * 헤더, JSON 응답을 조립합니다. 주요 의존성은 endpoint 설정, request parser, handler registry와 request context입니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpController + * @description 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @RestController public class McpController { @@ -37,6 +48,10 @@ public class McpController { /** * JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다. + * + * @param requestParser 처리할 요청 정보입니다. + * @param handlerRegistry 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. */ public McpController( JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, ObjectMapper objectMapper) { @@ -46,9 +61,10 @@ public class McpController { } /** - * 배포별 단일 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 응답을 - * 반환합니다. + * 배포별 단일 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 응답을 반환합니다. + * + * @param envelope 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ @PostMapping( value = {"${mcp.endpoint-path:/mcp}", "${mcp.endpoint-path:/mcp}/{routeKey}"}, @@ -91,8 +107,10 @@ public class McpController { } /** - * 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다. - * 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다. 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String toJson(Object value) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java index 527c75a..14e7a3b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java @@ -17,10 +17,19 @@ 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}가 - * 담당합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpExceptionHandler + * @description {@link McpController} 처리 중 발생한 예외를 AgentBuilder가 해석할 JSON-RPC 오류 응답으로 정규화하는 전용 예외 처리기입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @RestControllerAdvice(assignableTypes = McpController.class) public class McpExceptionHandler { @@ -29,6 +38,8 @@ public class McpExceptionHandler { /** * 모든 오류를 같은 trace 형식으로 기록하기 위해 logger를 주입받습니다. + * + * @param traceLogger 협력 객체입니다. */ public McpExceptionHandler(TraceLogger traceLogger) { this.traceLogger = traceLogger; @@ -36,6 +47,9 @@ public class McpExceptionHandler { /** * 서버가 의도적으로 발생시킨 JSON-RPC 예외를 HTTP 200의 표준 실패 응답으로 변환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ @ExceptionHandler(JsonRpcException.class) public ResponseEntity
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) @@ -50,8 +59,14 @@ public class McpExchangeFilter implements Filter { private final McpRouteKeyValidator routeKeyValidator; /** - * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. - * 생성 시점에는 외부 시스템을 호출하지 않으며, 실제 MCP 요청이 들어왔을 때만 context와 검증 흐름을 시작합니다. + * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 생성 시점에는 외부 시스템을 호출하지 않으며, 실제 MCP 요청이 들어왔을 때만 context와 검증 흐름을 시작합니다. + * + * @param headerExtractor 처리할 값입니다. + * @param traceLogger 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param protocolVersionValidator 입력값입니다. + * @param routeKeyValidator 처리 대상 route key입니다. */ public McpExchangeFilter( McpRequestContextFactory headerExtractor, @@ -69,8 +84,11 @@ public class McpExchangeFilter implements Filter { } /** - * Servlet container가 호출하는 표준 필터 진입점입니다. - * HTTP가 아니거나 MCP endpoint 대상이 아닌 요청은 그대로 다음 filter로 넘기고, MCP 요청은 중복 실행 방어 후 내부 처리 메서드로 위임합니다. + * Servlet container가 호출하는 표준 필터 진입점입니다. HTTP가 아니거나 MCP endpoint 대상이 아닌 요청은 그대로 다음 filter로 넘기고, MCP 요청은 중복 실행 방어 후 내부 처리 메서드로 위임합니다. + * + * @param request 처리할 요청 정보입니다. + * @param response 응답에 사용할 객체입니다. + * @param filterChain 입력값입니다. */ @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) @@ -99,6 +117,9 @@ public class McpExchangeFilter implements Filter { /** * 설정된 MCP endpoint 이외의 다른 HTTP 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean shouldSkip(HttpServletRequest request) { if (!"POST".equalsIgnoreCase(request.getMethod())) { @@ -114,8 +135,11 @@ public class McpExchangeFilter implements Filter { } /** - * MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다. - * 헤더 검증, route 검증, request body 재사용 wrapper, protocol version 검증을 Controller 진입 전에 수행합니다. + * MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다. 헤더 검증, route 검증, request body 재사용 wrapper, protocol version 검증을 Controller 진입 전에 수행합니다. + * + * @param request 처리할 요청 정보입니다. + * @param response 응답에 사용할 객체입니다. + * @param filterChain 입력값입니다. */ private void doMcpFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -195,8 +219,10 @@ public class McpExchangeFilter implements Filter { } /** - * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. - * 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. + * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @param request 처리할 요청 정보입니다. */ private void validateKnownRoute(McpRequestContext context, HttpServletRequest request) { if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) { @@ -208,8 +234,10 @@ public class McpExchangeFilter implements Filter { } /** - * 설정된 endpoint path 자체가 {@code /mcp/core}처럼 route를 이미 포함한 고정 배포인지 확인합니다. - * 이 경우 route 선택이 동적 URL 입력에서 온 것이 아니므로 Registry 기반 동적 route 차단 대상에서 제외하고 기존 고정 endpoint 계약을 유지합니다. + * 설정된 endpoint path 자체가 {@code /mcp/core}처럼 route를 이미 포함한 고정 배포인지 확인합니다. 이 경우 route 선택이 동적 URL 입력에서 온 것이 아니므로 Registry 기반 동적 route 차단 대상에서 제외하고 기존 고정 endpoint 계약을 유지합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isFixedEndpointRequest(HttpServletRequest request) { String path = request.getRequestURI(); @@ -222,8 +250,10 @@ public class McpExchangeFilter implements Filter { } /** - * 임시 요청 본문 로그를 보기 쉽게 출력하기 위해 JSON이면 개행된 문자열로 변환합니다. - * JSON 파싱이나 직렬화가 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다. + * 임시 요청 본문 로그를 보기 쉽게 출력하기 위해 JSON이면 개행된 문자열로 변환합니다. JSON 파싱이나 직렬화가 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다. + * + * @param body 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String prettyJson(String body) { try { @@ -234,8 +264,10 @@ public class McpExchangeFilter implements Filter { } /** - * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. - * JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려주며, 실제 오류 계약은 뒤쪽 request adapter가 결정합니다. + * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려주며, 실제 오류 계약은 뒤쪽 request adapter가 결정합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String extractMethod(CachedBodyHttpServletRequest request) { try { @@ -248,6 +280,10 @@ public class McpExchangeFilter implements Filter { /** * 필터 단계의 JSON-RPC 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다. + * + * @param response 응답에 사용할 객체입니다. + * @param code 입력값입니다. + * @param details 처리할 값입니다. */ private void writeJsonRpcError( HttpServletResponse response, JsonRpcErrorCode code, Object details) throws IOException { @@ -259,6 +295,10 @@ public class McpExchangeFilter implements Filter { /** * initialize 이후 protocol version 헤더 누락·불일치를 HTTP 400 transport 오류로 작성합니다. + * + * @param response 응답에 사용할 객체입니다. + * @param context 현재 MCP 요청 context입니다. + * @param exception 처리 중 발생한 예외 정보입니다. */ private void writeProtocolVersionError( HttpServletResponse response, McpRequestContext context, ProtocolVersionException exception) @@ -271,8 +311,11 @@ public class McpExchangeFilter implements Filter { } /** - * 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다. - * MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다. + * 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다. MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다. + * + * @param response 응답에 사용할 객체입니다. + * @param name 대상 이름입니다. + * @param value 처리할 값입니다. */ private void setResponseHeaderIfPresent(HttpServletResponse response, String name, String value) { if (StringUtils.hasText(value)) { @@ -281,8 +324,11 @@ public class McpExchangeFilter implements Filter { } /** - * protocol version transport 오류 body를 구성합니다. - * {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. + * protocol version transport 오류 body를 구성합니다. {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. + * + * @param context 현재 MCP 요청 context입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private Map
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class McpProtocolVersionValidator { @@ -19,6 +30,8 @@ public class McpProtocolVersionValidator { /** * 서버가 지원하는 MCP protocol version 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public McpProtocolVersionValidator(McpProperties properties) { this.properties = properties; @@ -26,6 +39,9 @@ public class McpProtocolVersionValidator { /** * initialize 이후 요청에 MCP-Protocol-Version 헤더가 있는지, 지원 목록과 일치하는지 검사합니다. initialize 자체는 아직 version을 협상하는 단계이므로 검사하지 않습니다. + * + * @param request 처리할 요청 정보입니다. + * @param mcpMethod 처리할 값입니다. */ public void validatePostInitializeRequest(HttpServletRequest request, String mcpMethod) { if (mcpMethod == null || McpSchema.METHOD_INITIALIZE.equals(mcpMethod)) { @@ -40,13 +56,12 @@ public class McpProtocolVersionValidator { } } - /** - * protocol version 헤더 누락 또는 미지원 값을 filter가 JSON-RPC 오류로 변환하도록 전달하는 내부 예외입니다. 별도의 HTTP 응답을 만들지 않으며, 최종 응답 형식은 {@code McpExchangeFilter}의 책임입니다. - */ public static final class ProtocolVersionException extends RuntimeException { /** * 호출자에게 알려 줄 protocol version 거절 이유를 보존합니다. + * + * @param message 처리할 값입니다. */ public ProtocolVersionException(String message) { super(message); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java index d1b33fa..a8ac358 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java @@ -11,10 +11,19 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. - * filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더를 선택값으로 추출합니다. - * 표준 헤더의 필수 여부나 업무 형식은 판단하지 않고, 전달된 값을 정규화해 Tool Service 호출 단계로 넘깁니다. - * route와 MCP session correlation처럼 서버 경계에 필요한 값만 이 컴포넌트에서 검증합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpRequestContextFactory + * @description 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.06 j.h.w 최초생성 + * + **/ @Component public class McpRequestContextFactory { @@ -38,17 +47,19 @@ public class McpRequestContextFactory { private final McpProperties properties; /** - * 요청 context 생성에 필요한 MCP 설정을 주입받습니다. - * 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path, 표준 헤더, timeout 설정을 사용합니다. + * 요청 context 생성에 필요한 MCP 설정을 주입받습니다. 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path, 표준 헤더, timeout 설정을 사용합니다. + * + * @param properties MCP 설정 정보입니다. */ public McpRequestContextFactory(McpProperties properties) { this.properties = properties; } /** - * HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다. - * 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다. - * 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다. + * HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다. 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다. 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ public McpRequestContext extract(HttpServletRequest request) { String routeKey = routeKey(request); @@ -87,8 +98,10 @@ public class McpRequestContextFactory { } /** - * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다. - * Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다. + * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다. Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String routeKey(HttpServletRequest request) { String path = request.getRequestURI(); @@ -115,8 +128,9 @@ public class McpRequestContextFactory { } /** - * route가 생략된 요청의 처리 방식을 결정합니다. - * Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다. + * route가 생략된 요청의 처리 방식을 결정합니다. Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String defaultRouteKey() { String configuredRoute = configuredEndpointRouteKey(); @@ -130,8 +144,9 @@ public class McpRequestContextFactory { } /** - * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다. - * {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다. + * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다. {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String configuredEndpointRouteKey() { String basePath = properties.endpointPath(); @@ -146,8 +161,11 @@ public class McpRequestContextFactory { } /** - * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. - * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. + * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. + * + * @param value 처리할 값입니다. + * @param header 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String validatedOptional(String value, String header) { String normalized = trimToNull(value); @@ -158,8 +176,10 @@ public class McpRequestContextFactory { } /** - * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다. - * 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다. + * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다. 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다. + * + * @param value 처리할 값입니다. + * @param header 처리할 값입니다. */ private void validate(String value, String header) { if (!SAFE_CORRELATION_ID.matcher(value).matches()) { @@ -170,8 +190,10 @@ public class McpRequestContextFactory { } /** - * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다. - * Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다. + * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다. Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java index e9d2a26..fe36dae 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java @@ -1,14 +1,27 @@ package io.shinhanlife.dat.biz.mcp.transport.http; /** - * MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다. - * HTTP 요청을 직접 처리하지 않고 {@link McpExchangeFilter}가 controller 진입 전에 호출하며, 실제 판단 기준은 Registry 계층의 메모리 snapshot 구현이 제공합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpRouteKeyValidator + * @description MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다. + * @author j.h.w + * @create 2026.08.19 + * + *
+ * ============ 개정이력 ============ + * 수정일 수정자 수정내용 + * ---------- ---------- ---------------- + * 2026.08.19 j.h.w 최초생성 + * + **/ public interface McpRouteKeyValidator { /** - * 주어진 route key가 현재 허용 가능한지 확인합니다. - * 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + * 주어진 route key가 현재 허용 가능한지 확인합니다. 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ boolean isKnownRoute(String routeKey); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application-ocp.yml b/src/main/resources/application-ocp.yml deleted file mode 100644 index 3ab829f..0000000 --- a/src/main/resources/application-ocp.yml +++ /dev/null @@ -1,13 +0,0 @@ -mcp: - identity: ${MCP_IDENTITY} - discovery: - enabled: true - redis: - enabled: true - -management: - server: - port: ${MANAGEMENT_SERVER_PORT:9090} - health: - redis: - enabled: false diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 12cf8a0..955443a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,6 +67,17 @@ mcp: # 270s leaves a 30s margin to serialize and write the timeout response. request-deadline-millis: 270000 forward-authorization: false + retry: + enabled: true + max-attempts: 2 + backoff-millis: 200 + retry-on-http-status: + - 408 + - 429 + - 500 + - 502 + - 503 + - 504 redis: enabled: true key-prefix: axhub:mcp:tools @@ -93,7 +104,8 @@ mcp: # nothing a Tool Service returns can change where MCP sends the call. bundles: [] trace: - enabled: true # Rejects oversized MCP request bodies before controller processing. + enabled: true + # Rejects oversized MCP request bodies before controller processing. max-body-bytes: 1048576 logging: diff --git a/src/main/resources/glow/application-glow-dev.yml b/src/main/resources/glow/application-glow-dev.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-local.yml b/src/main/resources/glow/application-glow-local.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-prod.yml b/src/main/resources/glow/application-glow-prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-test.yml b/src/main/resources/glow/application-glow-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow.yml b/src/main/resources/glow/application-glow.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java index 1919cab..afaad45 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java @@ -296,7 +296,7 @@ class HelmDeploymentContractTest { assertThat(deployment) .contains("name: SPRING_PROFILES_ACTIVE") - .contains("value: ocp") + .contains("value: prod") .contains("SPRING_CONFIG_ADDITIONAL_LOCATION") .contains("checksum/config:") .contains("replicas: {{ $tier.replicas }}"); diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java index 22bd02c..9880c3b 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,6 +72,84 @@ class ToolExecutionServiceTest { verify(client).execute(request, requestContext); } + @Test + void retriesRetryableToolForTransientHttpStatus() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + McpRequestContext requestContext = context(); + ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/retryable"); + ToolRequest request = + new ToolRequest( + "customer.search", + "1.0.0", + "http://tool/retryable", + call.arguments(), + 3_000, + java.util.List.of(500), + 2, + 1, + true); + when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, requestContext)) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 500: customer.search", + null, + 500)) + .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"retried\":true}"))); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + var result = service.execute(call, requestContext); + + assertThat(result.data().path("retried").asBoolean()).isTrue(); + verify(client, times(2)).execute(request, requestContext); + verify(registry, never()).refresh(requestContext.routeKey()); + } + + @Test + void doesNotRetryUnsafeToolForTransientHttpStatus() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + McpRequestContext requestContext = context(); + ToolCall call = new ToolCall("customer.update", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/unsafe"); + ToolRequest request = + new ToolRequest( + "customer.update", + "1.0.0", + "http://tool/unsafe", + call.arguments(), + 3_000, + java.util.List.of(500), + 2, + 1, + false); + when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, requestContext)) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 500: customer.update", + null, + 500)); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + assertThatThrownBy(() -> service.execute(call, requestContext)) + .isInstanceOf(JsonRpcException.class) + .hasMessageContaining("Tool returned HTTP 500"); + + verify(client).execute(request, requestContext); + verify(registry, never()).refresh(requestContext.routeKey()); + } + @Test void refreshesRouteWhenDeletedToolReturnsNotFound() throws Exception { ToolRegistryService registry = mock(ToolRegistryService.class); diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java index 1cb13aa..5ac8d85 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java @@ -41,4 +41,47 @@ class ToolRoutingServiceTest { assertThat(request.endpoint()).isEqualTo("http://localhost:9090/internal/tools/customer-search"); } + + @Test + void enablesRetryForReadOnlyToolAnnotations() throws Exception { + ToolMetadata metadata = metadataWithAnnotations( + "customer.search", + "http://localhost:9090/internal/tools/customer-search", + "{\"readOnlyHint\":true,\"idempotentHint\":false,\"destructiveHint\":false}"); + ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.retryable()).isTrue(); + assertThat(request.maxAttempts()).isEqualTo(2); + assertThat(request.backoffMillis()).isEqualTo(200); + assertThat(request.retryOnHttpStatus()).containsExactly(408, 429, 500, 502, 503, 504); + } + + @Test + void disablesRetryForDestructiveToolAnnotations() throws Exception { + ToolMetadata metadata = metadataWithAnnotations( + "customer.delete", + "http://localhost:9090/internal/tools/customer-delete", + "{\"readOnlyHint\":true,\"idempotentHint\":true,\"destructiveHint\":true}"); + ToolCall call = new ToolCall("customer.delete", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.retryable()).isFalse(); + assertThat(request.maxAttempts()).isEqualTo(2); + } + + private ToolMetadata metadataWithAnnotations(String name, String endpoint, String annotations) throws Exception { + return new ToolMetadata( + name, + "1.0.0", + "tool", + endpoint, + OBJECT_MAPPER.readTree("{\"type\":\"object\"}"), + 3_000, + true, + OBJECT_MAPPER.readTree("{\"annotations\":" + annotations + "}"), + true); + } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java index bf3316a..6911227 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java @@ -16,7 +16,7 @@ import org.springframework.context.ApplicationContext; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { - "spring.profiles.active=ocp", + "spring.profiles.active=prod", "mcp.identity=test-mcp", "mcp.discovery.enabled=true", "mcp.bundles[0].id=bundle-a",