Relax standard header validation and preserve optional values
Some checks failed
Deploy Gateway / deploy (push) Failing after 0s

This commit is contained in:
2026-08-28 14:03:20 +09:00
parent c9a6bd2b0a
commit 2e997b1819
6 changed files with 130 additions and 253 deletions

View File

@@ -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도 기본 로그에 남기지 않는다.

View File

@@ -24,7 +24,7 @@ public record McpProperties(
@Pattern(
regexp = "^/mcp(?:/[a-z0-9-]+)?$",
message = "must be /mcp or /mcp/<lowercase-alphanumeric-hyphen>")
String endpointPath,
String endpointPath,
@Valid Server server,
@Valid Registry registry,
@Valid ToolClient toolClient,

View File

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

View File

@@ -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<String, Object> protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) {
Map<String, Object> 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;
}
/**

View File

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

View File

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