diff --git a/README.md b/README.md index 131f697..1cc7526 100644 --- a/README.md +++ b/README.md @@ -80,22 +80,22 @@ Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정 ## Correlation과 로그 -Agent Builder가 보내는 표준 헤더는 12개다. MCP는 권한 판정을 하지 않지만, 필수값·형식·요청 시각 오차는 HTTP 입력 경계에서 검증한다. +Agent Builder가 보낼 수 있는 표준 헤더는 12개다. MCP는 권한 판정이나 헤더 업무 검증을 하지 않고, 표준 헤더를 필수로 요구하지 않는다. 값이 없으면 없는 상태로 두며, 이 표준 헤더 때문에 요청을 차단하지 않는다. -| 헤더 | 형식/조건 | MCP 동작 | +| 헤더 | 기대 형식 | MCP 동작 | |---|---|---| -| `X-Guid` | UUID V4 | end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용 | -| `X-Praf-No` | 숫자 8자리. `BATCH`는 공란 허용 | Tool Service로 전달. 권한 판단은 하지 않음 | -| `X-Request-Id` | UUID V4 | Agent→MCP 요청 식별자. Tool Service 호출 시에는 새 UUID V4로 재채번 | -| `X-Request-Time` | ISO-8601 offset date-time, 서버 시각 ±5분 | Agent→MCP 요청 시각. Tool Service 호출 시에는 재채번 시각으로 교체 | -| `X-Vrtl-Praf-No` | `X-Praf-No`가 `0986`으로 시작하면 숫자 8자리 필수, 그 외에는 헤더 제거 | 조건만 검증하고 Tool Service로 전달 | -| `X-App-Code` | 대문자 3자리 | Tool Service로 전달 | -| `X-Project-Code` | 대문자 5자리 | Tool Service로 전달 | -| `X-User-Ip` | 사용자 단말 IP. `BATCH`는 공란 허용 | Tool Service로 전달 | -| `X-Caller-IP` | 호출 서버 IP | Tool Service 호출 시 MCP 서버 IP로 교체 | -| `X-Caller-Host` | 호출 서버 host name | Tool Service 호출 시 MCP 서버 host로 교체 | -| `X-Channel` | `SCRN`, `BATCH`, `MSGR`, `MCP`, `EAI` | Tool Service 호출 시 `MCP`로 교체 | -| `X-Agent-Id` | API 호출이면 생략 가능 | 있으면 Tool Service로 전달 | +| `X-Guid` | UUID V4 | 있으면 end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용하고, 없으면 생략 | +| `X-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달. 권한 판단은 하지 않음 | +| `X-Request-Id` | UUID V4 | 있으면 Agent→MCP 요청 식별자로 사용. Tool Service 호출 시에는 새 UUID V4로 재채번 | +| `X-Request-Time` | ISO-8601 offset date-time | 있으면 수신 context에 보관. Tool Service 호출 시에는 재채번 시각으로 교체 | +| `X-Vrtl-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달 | +| `X-App-Code` | 대문자 3자리 | 있으면 Tool Service로 전달 | +| `X-Project-Code` | 대문자 5자리 | 있으면 Tool Service로 전달 | +| `X-User-Ip` | 사용자 단말 IP | 있으면 Tool Service로 전달 | +| `X-Caller-IP` | 호출 서버 IP | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 IP로 교체 | +| `X-Caller-Host` | 호출 서버 host name | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 host로 교체 | +| `X-Channel` | 채널 코드 | 있으면 수신 context에 보관. Tool Service 호출 시 `MCP`로 교체 | +| `X-Agent-Id` | Agent 식별자 | 있으면 Tool Service로 전달 | MCP는 사번·가상사번·사용자 IP를 로그에 남기지 않는다. MDC는 사용하지 않으며 로그에는 `X-Guid`와 MCP가 받은 `X-Request-Id`만 남긴다. request/response body와 credential도 기본 로그에 남기지 않는다. 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 9892f46..78b10c8 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 @@ -24,7 +24,7 @@ public record McpProperties( @Pattern( regexp = "^/mcp(?:/[a-z0-9-]+)?$", message = "must be /mcp or /mcp/") - String endpointPath, + String endpointPath, @Valid Server server, @Valid Registry registry, @Valid ToolClient toolClient, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java index f9f8987..a5ddc6a 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java @@ -170,7 +170,7 @@ public class HttpToolClient implements ToolClient { /** * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다. - * BATCH에서 공란으로 들어온 사번·사용자 IP는 빈 문자열 그대로 전달해 호출 성격을 유지합니다. + * Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다. */ private void set(org.springframework.http.HttpHeaders headers, String name, String value) { if (value != null) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java index d4ef5f9..c32eb50 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java @@ -19,6 +19,7 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.LinkedHashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,10 +27,11 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; /** * 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 표준 Servlet 필터입니다. - * Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다. + * Agent Builder가 보낸 guid와 개별 HTTP requestId가 있으면 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다. * Spring {@code OncePerRequestFilter} 초기화 경로를 사용하지 않기 위해 {@link Filter}를 직접 구현하며, 중복 실행 방어는 request attribute로 수행합니다. * 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다. */ @@ -122,8 +124,8 @@ public class McpExchangeFilter implements Filter { McpRequestContext context = headerExtractor.extract(request); validateKnownRoute(context, request); McpRequestContextHolder.set(context); - response.setHeader(McpRequestContextFactory.HEADER_GUID, context.guid()); - response.setHeader(McpRequestContextFactory.HEADER_REQUEST_ID, context.requestId()); + setResponseHeaderIfPresent(response, McpRequestContextFactory.HEADER_GUID, context.guid()); + setResponseHeaderIfPresent(response, McpRequestContextFactory.HEADER_REQUEST_ID, context.requestId()); CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request, properties.trace().maxBodyBytes()); @@ -263,13 +265,34 @@ public class McpExchangeFilter implements Filter { throws IOException { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - objectMapper.writeValue( + objectMapper.writeValue( response.getOutputStream(), - Map.of( - "error", "Invalid MCP protocol version", - "message", exception.getMessage(), - "supportedVersions", properties.protocol().supportedVersions(), - "guid", context.guid())); + protocolVersionErrorBody(context, exception)); + } + + /** + * 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다. + * MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다. + */ + private void setResponseHeaderIfPresent(HttpServletResponse response, String name, String value) { + if (StringUtils.hasText(value)) { + response.setHeader(name, value); + } + } + + /** + * protocol version transport 오류 body를 구성합니다. + * {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. + */ + private Map protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) { + Map body = new LinkedHashMap<>(); + body.put("error", "Invalid MCP protocol version"); + body.put("message", exception.getMessage()); + body.put("supportedVersions", properties.protocol().supportedVersions()); + if (StringUtils.hasText(context.guid())) { + body.put("guid", context.guid()); + } + return body; } /** 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 3ac6b9f..d1b33fa 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 @@ -5,20 +5,16 @@ import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import jakarta.servlet.http.HttpServletRequest; -import java.time.Duration; import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.format.DateTimeParseException; -import java.util.Set; -import java.util.UUID; import java.util.regex.Pattern; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. - * filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더의 필수 여부와 형식을 검증합니다. - * 권한 판정이나 업무 의미 해석은 하지 않고, 검증된 값을 요청 context에 보관해 Tool Service 호출 단계로 넘깁니다. + * filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더를 선택값으로 추출합니다. + * 표준 헤더의 필수 여부나 업무 형식은 판단하지 않고, 전달된 값을 정규화해 Tool Service 호출 단계로 넘깁니다. + * route와 MCP session correlation처럼 서버 경계에 필요한 값만 이 컴포넌트에서 검증합니다. */ @Component public class McpRequestContextFactory { @@ -38,18 +34,6 @@ public class McpRequestContextFactory { private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); private static final Pattern SAFE_ROUTE_KEY = Pattern.compile("[A-Za-z0-9._-]{1,64}"); - private static final Pattern UUID_V4 = Pattern.compile( - "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"); - private static final Pattern EIGHT_DIGITS = Pattern.compile("\\d{8}"); - private static final Pattern APP_CODE = Pattern.compile("[A-Z]{3}"); - private static final Pattern PROJECT_CODE = Pattern.compile("[A-Z]{5}"); - private static final Pattern CALLER_HOST = Pattern.compile("[A-Za-z0-9._-]{1,255}"); - private static final Pattern IPV4 = Pattern.compile( - "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}"); - private static final Pattern IPV6_LIKE = Pattern.compile("[0-9A-Fa-f:.%]{2,64}"); - private static final Pattern SAFE_AGENT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); - private static final Set CHANNELS = Set.of("SCRN", "BATCH", "MSGR", "MCP", "EAI"); - private static final Duration REQUEST_TIME_TOLERANCE = Duration.ofMinutes(5); private final McpProperties properties; @@ -62,25 +46,25 @@ public class McpRequestContextFactory { } /** - * HTTP 요청에서 route key와 표준 헤더를 추출해 불변 context로 만듭니다. - * 누락·형식 오류·요청 시각 오차는 controller 진입 전에 JSON-RPC invalid request 예외로 거부하고, 권한 관련 판단은 하지 않습니다. + * HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다. + * 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다. + * 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다. */ public McpRequestContext extract(HttpServletRequest request) { String routeKey = routeKey(request); String authorization = trimToNull(request.getHeader("Authorization")); - String channel = requiredEnum(request, HEADER_CHANNEL, CHANNELS); - boolean batch = "BATCH".equals(channel); - String guid = requiredUuidV4(request, HEADER_GUID); - String requestId = requiredUuidV4(request, HEADER_REQUEST_ID); - String requestTime = requiredRequestTime(request); - String prafNo = employeeNo(request, HEADER_PRAF_NO, batch); - String virtualPrafNo = virtualPrafNo(request, prafNo, batch); - String appCode = requiredPattern(request, HEADER_APP_CODE, APP_CODE, "must be 3 uppercase letters"); - String projectCode = requiredPattern(request, HEADER_PROJECT_CODE, PROJECT_CODE, "must be 5 uppercase letters"); - String userIp = ipAddress(request, HEADER_USER_IP, batch); - String callerIp = ipAddress(request, HEADER_CALLER_IP, false); - String callerHost = requiredPattern(request, HEADER_CALLER_HOST, CALLER_HOST, "must be a safe host name"); - String agentId = optionalPattern(request, HEADER_AGENT_ID, SAFE_AGENT_ID, "must be a safe identifier"); + String channel = trimToNull(request.getHeader(HEADER_CHANNEL)); + String guid = trimToNull(request.getHeader(HEADER_GUID)); + String requestId = trimToNull(request.getHeader(HEADER_REQUEST_ID)); + String requestTime = trimToNull(request.getHeader(HEADER_REQUEST_TIME)); + String prafNo = trimToNull(request.getHeader(HEADER_PRAF_NO)); + String virtualPrafNo = trimToNull(request.getHeader(HEADER_VRTL_PRAF_NO)); + String appCode = trimToNull(request.getHeader(HEADER_APP_CODE)); + String projectCode = trimToNull(request.getHeader(HEADER_PROJECT_CODE)); + String userIp = trimToNull(request.getHeader(HEADER_USER_IP)); + String callerIp = trimToNull(request.getHeader(HEADER_CALLER_IP)); + String callerHost = trimToNull(request.getHeader(HEADER_CALLER_HOST)); + String agentId = trimToNull(request.getHeader(HEADER_AGENT_ID)); String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id"); return new McpRequestContext( @@ -161,164 +145,6 @@ public class McpRequestContextFactory { return null; } - /** - * 필수 UUID V4 헤더를 읽어 canonical 형식인지 검증합니다. - * 누락되거나 UUID V4가 아니면 요청 추적 계약 위반으로 보고 invalid request 예외를 발생시킵니다. - */ - private String requiredUuidV4(HttpServletRequest request, String header) { - String value = required(request, header); - if (!UUID_V4.matcher(value).matches()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4"); - } - try { - return UUID.fromString(value).toString(); - } catch (IllegalArgumentException exception) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4"); - } - } - - /** - * {@code X-Request-Time}을 ISO-8601 offset 시각으로 파싱하고 현재 시각과 5분 이상 차이 나는 요청을 거부합니다. - * request replay나 오래 지연된 호출이 Tool Service까지 전달되지 않도록 입력 경계에서 차단합니다. - */ - private String requiredRequestTime(HttpServletRequest request) { - String value = required(request, HEADER_REQUEST_TIME); - try { - Instant requestInstant = OffsetDateTime.parse(value).toInstant(); - Duration skew = Duration.between(requestInstant, Instant.now()).abs(); - if (skew.compareTo(REQUEST_TIME_TOLERANCE) > 0) { - throw new JsonRpcException( - JsonRpcErrorCode.INVALID_REQUEST, - HEADER_REQUEST_TIME + " must be within 5 minutes of server time"); - } - return value; - } catch (DateTimeParseException exception) { - throw new JsonRpcException( - JsonRpcErrorCode.INVALID_REQUEST, - HEADER_REQUEST_TIME + " must be ISO-8601 offset date-time"); - } - } - - /** - * 사번 헤더를 검증합니다. - * 화면·메신저·MCP·EAI 호출은 숫자 8자리를 요구하고, 사람이 없는 BATCH 호출은 공란 또는 누락을 허용합니다. - */ - private String employeeNo(HttpServletRequest request, String header, boolean batch) { - String value = valueOrEmpty(request.getHeader(header)); - if (batch && value.isEmpty()) { - return ""; - } - if (!EIGHT_DIGITS.matcher(value).matches()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be 8 digits"); - } - return value; - } - - /** - * 협력사 사번의 가상사번 조건을 검증합니다. - * {@code 0986}으로 시작하는 사번은 가상사번을 필수로 받고, 그 외 호출에서는 빈 문자열을 포함해 전달된 헤더 값을 거부합니다. - */ - private String virtualPrafNo(HttpServletRequest request, String prafNo, boolean batch) { - boolean present = hasHeader(request, HEADER_VRTL_PRAF_NO); - String value = valueOrEmpty(request.getHeader(HEADER_VRTL_PRAF_NO)); - if (!batch && prafNo.startsWith("0986")) { - if (!EIGHT_DIGITS.matcher(value).matches()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " is required for partner employee"); - } - return value; - } - if (present) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " must be absent for non-partner employee"); - } - return null; - } - - /** - * IP 헤더를 검증합니다. - * BATCH의 사용자 IP만 공란 또는 누락을 허용하고, 그 외 IP는 IPv4 또는 IPv6 형태의 안전한 값만 통과시킵니다. - */ - private String ipAddress(HttpServletRequest request, String header, boolean blankAllowed) { - String value = valueOrEmpty(request.getHeader(header)); - if (blankAllowed && value.isEmpty()) { - return ""; - } - if (!isIpAddress(value)) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be an IP address"); - } - return value; - } - - /** - * 필수 헤더 값을 지정한 정규식으로 검증합니다. - * 값이 없거나 형식이 다르면 Tool 실행 전 단계에서 invalid request 예외를 발생시킵니다. - */ - private String requiredPattern(HttpServletRequest request, String header, Pattern pattern, String message) { - String value = required(request, header); - if (!pattern.matcher(value).matches()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message); - } - return value; - } - - /** - * 선택 헤더가 있을 때만 지정한 정규식으로 검증합니다. - * Agent ID처럼 API 호출에서는 빠질 수 있는 값은 누락을 허용하되, 전달된 값의 header injection 위험은 차단합니다. - */ - private String optionalPattern(HttpServletRequest request, String header, Pattern pattern, String message) { - String value = trimToNull(request.getHeader(header)); - if (value == null) { - return null; - } - if (!pattern.matcher(value).matches()) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message); - } - return value; - } - - /** - * 필수 enum 헤더가 허용된 코드 집합에 포함되는지 검증합니다. - * 현재는 화면·배치·메신저·MCP·EAI 채널만 허용합니다. - */ - private String requiredEnum(HttpServletRequest request, String header, Set allowed) { - String value = required(request, header); - if (!allowed.contains(value)) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is invalid"); - } - return value; - } - - /** - * 필수 헤더를 공백 제거 후 반환합니다. - * 누락되거나 공백뿐이면 표준 헤더 계약 위반으로 보고 invalid request 예외를 발생시킵니다. - */ - private String required(HttpServletRequest request, String header) { - String value = trimToNull(request.getHeader(header)); - if (value == null) { - throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is required"); - } - return value; - } - - /** - * HTTP 요청에 특정 헤더가 실제로 포함되어 있었는지 확인합니다. - * 비협력사 호출에서 빈 가상사번 헤더를 보낸 경우도 제거되지 않은 것으로 판단하기 위해 값 조회와 별도로 사용합니다. - */ - private boolean hasHeader(HttpServletRequest request, String header) { - return request.getHeaderNames().asIterator().hasNext() - && request.getHeaders(header).asIterator().hasNext(); - } - - /** - * IPv4는 octet 범위를 엄격히 확인하고, IPv6는 주소 문자 집합과 콜론 포함 여부를 기준으로 안전한 문자열만 허용합니다. - * DNS 조회는 하지 않아 요청 처리 지연이나 외부 의존성을 만들지 않습니다. - */ - private boolean isIpAddress(String value) { - if (!StringUtils.hasText(value)) { - return false; - } - return IPV4.matcher(value).matches() || (value.contains(":") && IPV6_LIKE.matcher(value).matches()); - } - /** * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. @@ -350,12 +176,4 @@ public class McpRequestContextFactory { private String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - - /** - * null은 빈 문자열로 정규화하되, 입력값의 공백은 보존합니다. - * 사번과 IP처럼 정확한 형식이 필요한 헤더에서 공백 포함 값을 실수로 허용하지 않기 위해 사용합니다. - */ - private String valueOrEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java index 6b8c811..d34680b 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java @@ -15,7 +15,6 @@ import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_ID; import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_TIME; import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_USER_IP; -import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_VRTL_PRAF_NO; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; @@ -74,10 +73,10 @@ class McpExchangeFilterTest { } @Test - void rejectsMissingRequiredStandardHeaderBeforeController() throws Exception { + void acceptsRequestWithoutStandardHeadersWithoutGeneratingCorrelationValues() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader(HEADER_GUID, GUID); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); request.setContent( """ {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} @@ -88,12 +87,16 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called without required headers"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().guid()).isNull(); + assertThat(McpRequestContextHolder.require().requestId()).isNull(); + assertThat(McpRequestContextHolder.require().requestTime()).isNull(); + wrappedResponse.setContentType("application/json"); }); assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_CHANNEL + " is required"); + assertThat(response.getHeader(HEADER_GUID)).isNull(); + assertThat(response.getHeader(HEADER_REQUEST_ID)).isNull(); } @Test @@ -119,8 +122,34 @@ class McpExchangeFilterTest { request, response, (wrappedRequest, wrappedResponse) -> { - assertThat(McpRequestContextHolder.require().prafNo()).isEmpty(); - assertThat(McpRequestContextHolder.require().userIp()).isEmpty(); + assertThat(McpRequestContextHolder.require().prafNo()).isNull(); + assertThat(McpRequestContextHolder.require().userIp()).isNull(); + wrappedResponse.setContentType("application/json"); + }); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void acceptsUnknownChannelCodeWithoutValidation() throws Exception { + McpExchangeFilter filter = filter(properties(false, false)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); + addStandardHeaders(request); + request.removeHeader(HEADER_CHANNEL); + request.addHeader(HEADER_CHANNEL, "PORTAL_API"); + request.addHeader("MCP-Protocol-Version", "2025-11-25"); + request.setContent( + """ + {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}} + """ + .getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().channel()).isEqualTo("PORTAL_API"); wrappedResponse.setContentType("application/json"); }); @@ -152,7 +181,7 @@ class McpExchangeFilterTest { } @Test - void rejectsEmployeeNumberContainingHeaderInjection() throws Exception { + void acceptsEmployeeNumberContainingHeaderInjectionWithoutHeaderValidation() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); addStandardHeaders(request); @@ -169,15 +198,16 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("12345678\r\nx-injected: evil"); + wrappedResponse.setContentType("application/json"); }); - assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits"); + assertThat(response.getStatus()).isEqualTo(200); } @Test - void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception { + void trimsOptionalEmployeeNumberBeforeValidation() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); addStandardHeaders(request); @@ -194,15 +224,16 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("12345678"); + wrappedResponse.setContentType("application/json"); }); - assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits"); + assertThat(response.getStatus()).isEqualTo(200); } @Test - void rejectsGuidThatIsNotUuidV4() throws Exception { + void acceptsGuidThatIsNotUuidV4WithoutHeaderValidation() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); addStandardHeaders(request); @@ -219,15 +250,17 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called for a non-UUID guid"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().guid()).isEqualTo("guid-1"); + wrappedResponse.setContentType("application/json"); }); - assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_GUID + " must be a UUID v4"); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeader(HEADER_GUID)).isEqualTo("guid-1"); } @Test - void rejectsRequestTimeOutsideFiveMinutes() throws Exception { + void acceptsRequestTimeOutsidePreviousToleranceWithoutHeaderValidation() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); addStandardHeaders(request); @@ -239,15 +272,16 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called for stale request time"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().requestTime()).isEqualTo("2000-01-01T00:00:00+09:00"); + wrappedResponse.setContentType("application/json"); }); - assertThat(response.getContentAsString()).contains("\"code\":-32600", "within 5 minutes"); + assertThat(response.getStatus()).isEqualTo(200); } @Test - void requiresVirtualEmployeeNoForPartnerEmployee() throws Exception { + void acceptsPartnerEmployeeWithoutVirtualEmployeeNo() throws Exception { McpExchangeFilter filter = filter(properties(false, false)); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); addStandardHeaders(request); @@ -259,11 +293,13 @@ class McpExchangeFilterTest { filter.doFilter( request, response, - (ignoredRequest, ignoredResponse) -> { - throw new AssertionError("Controller chain must not be called without virtual employee number"); + (wrappedRequest, wrappedResponse) -> { + assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("09861234"); + assertThat(McpRequestContextHolder.require().virtualPrafNo()).isNull(); + wrappedResponse.setContentType("application/json"); }); - assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_VRTL_PRAF_NO + " is required"); + assertThat(response.getStatus()).isEqualTo(200); } @Test