From 1d26903b79e250a08fa9403c9b046a0e9839feca Mon Sep 17 00:00:00 2001 From: Neeraj Bhatt Date: Mon, 9 Nov 2026 09:51:40 +0530 Subject: [PATCH] Forward Mcp-Name header in McpTransportContext Expose the decoded Mcp-Name request header to server features through a new McpNameHeader value stored under McpNameHeader.TRANSPORT_CONTEXT_KEY. - HTTP: every validated JSON-RPC request carries a value, attached after the user's context extractor so it always wins. The value is either ABSENT, MALFORMED (bad base64, repeated values, or names differing only in case), or the decoded value. - Stdio: carries the NOT_MIRRORED sentinel, so features can tell "no headers on this transport" apart from "client omitted the header". - Decoding reuses McpHeaderDecoder and reads every header name and value through the header accessor. toString() never includes the value. The transport only decodes and forwards. For methods it does not validate itself, the feature owns the equality check against params and should treat a malformed value as a mismatch. Existing validateHeaders() behavior is unchanged. Fixes #1317 Co-Authored-By: Claude Opus 5.5 --- .../modern/server/McpNameHeader.java | 114 ++++++++++++++++++ .../transport/HttpServletMcpTransport.java | 6 +- .../server/transport/McpHeaderDecoder.java | 26 ++++ .../server/transport/StdioMcpTransport.java | 7 +- .../transport/McpHeaderDecoderTests.java | 84 +++++++++++++ ...tpServletMcpTransportIntegrationTests.java | 86 ++++++++++++- .../transport/StdioMcpTransportTests.java | 6 +- 7 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java new file mode 100644 index 000000000..cb9e8637c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpNameHeader.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.util.Assert; +import reactor.util.annotation.Nullable; + +/** + * The decoded {@code Mcp-Name} header of a JSON-RPC request over HTTP, or its absence or + * malformed state. + *

+ * An HTTP transport decodes the header and adds it to the transport context under + * {@link #TRANSPORT_CONTEXT_KEY} on every JSON-RPC request. Transports without headers + * use {@link #NOT_MIRRORED} instead. The transport only decodes and forwards this value; + * features whose methods the transport does not validate (for example, {@code tasks/get}, + * {@code tasks/update}, and {@code tasks/cancel}) must compare it against their request + * parameters and treat {@link #isMalformed()} as a mismatch. + *

+ * Header names are matched ignoring case. A present header either has a decoded value or + * is malformed, never both. + * + * @author Neeraj Bhatt + * @see SEP-2243: + * HTTP Standardization + * @see SEP-2663: + * Streamable HTTP Routing Headers + */ +public final class McpNameHeader { + + /** + * The transport context key under which a transport provides the {@code Mcp-Name} + * header of a JSON-RPC request. + */ + public static final String TRANSPORT_CONTEXT_KEY = McpNameHeader.class.getName(); + + /** + * Marks a transport without headers, such as stdio. Compared by identity with + * {@link #ABSENT} to distinguish it from an HTTP request without the header. + */ + public static final McpNameHeader NOT_MIRRORED = new McpNameHeader(State.NOT_MIRRORED, null); + + /** + * Marks an HTTP request without the {@code Mcp-Name} header. Compared by identity + * with {@link #NOT_MIRRORED} to distinguish the two states. + */ + public static final McpNameHeader ABSENT = new McpNameHeader(State.ABSENT, null); + + /** + * Marks a present header whose value was missing, could not be decoded, or was sent + * more than once. + */ + public static final McpNameHeader MALFORMED = new McpNameHeader(State.MALFORMED, null); + + private final State state; + + @Nullable + private final String value; + + private McpNameHeader(State state, @Nullable String value) { + this.state = state; + this.value = value; + } + + /** + * Creates a header holding a decoded value. + * @param decodedValue the decoded {@code Mcp-Name} value, not {@code null} + * @return the present header + */ + public static McpNameHeader of(String decodedValue) { + Assert.notNull(decodedValue, "decodedValue must not be null"); + return new McpNameHeader(State.PRESENT, decodedValue); + } + + /** + * Whether the header was sent, whether its value is valid or malformed. + * @return {@code true} if the header is present + */ + public boolean isPresent() { + return this.state == State.PRESENT || this.state == State.MALFORMED; + } + + /** + * Whether the header was sent but had no valid decoded value. + * @return {@code true} if the header is malformed + */ + public boolean isMalformed() { + return this.state == State.MALFORMED; + } + + /** + * The decoded value of a present, valid header. + * @return the decoded value, or {@code null} if the header is absent, malformed, or + * the transport has no headers + */ + @Nullable + public String value() { + return this.value; + } + + @Override + public String toString() { + return "McpNameHeader[" + this.state + "]"; + } + + private enum State { + + NOT_MIRRORED, ABSENT, MALFORMED, PRESENT + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java index 2362b03b0..1657328c3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java @@ -28,6 +28,7 @@ import io.modelcontextprotocol.modern.McpSchema; import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.MetaKeys; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpServer; @@ -240,8 +241,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + // Added after the user's extractor ran, so these entries always win. + var mcpNameHeader = McpHeaderDecoder.nameHeader(headerAccessor(request)); + transportContext = transportContext.putAll(Map.of(McpNameHeader.TRANSPORT_CONTEXT_KEY, mcpNameHeader)); + if (McpSchema.METHOD_TOOLS_CALL.equals(jsonRpcRequest.method())) { - // Added after the user's extractor ran, so this entry always wins. var mcpParamHeaders = McpHeaderDecoder.paramHeaders(headerAccessor(request)); transportContext = transportContext.putAll(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, mcpParamHeaders)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java index 818e78d06..8939f21e0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoder.java @@ -17,6 +17,7 @@ import java.util.Map; import java.util.Set; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.server.transport.HeaderAccessor; import reactor.util.annotation.Nullable; @@ -90,6 +91,31 @@ public static String decodeValue(String raw) { return raw; } + /** + * Collects the {@code Mcp-Name} header, ignoring the header name's case. A repeated + * header or an undecodable value is marked malformed. + * @param headers the request headers + * @return the decoded header, or its absent or malformed state + */ + public static McpNameHeader nameHeader(HeaderAccessor headers) { + List values = new ArrayList<>(); + boolean found = false; + for (String name : headers.getHeaderNames()) { + if ("Mcp-Name".equalsIgnoreCase(name)) { + found = true; + values.addAll(headers.getHeader(name)); + } + } + if (!found) { + return McpNameHeader.ABSENT; + } + if (values.size() != 1) { + return McpNameHeader.MALFORMED; + } + String decoded = decodeValue(values.get(0)); + return decoded == null ? McpNameHeader.MALFORMED : McpNameHeader.of(decoded); + } + /** * Collects the {@code Mcp-Param-{Name}} headers of a request. *

diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java index e908992b0..56a30a137 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransport.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse; import io.modelcontextprotocol.modern.JsonRpc.JSONRPCResponse.JSONRPCError; import io.modelcontextprotocol.modern.McpSchema; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestManager; import io.modelcontextprotocol.modern.server.McpTransportResponse; @@ -51,10 +52,10 @@ public class StdioMcpTransport { private static final int DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024; - // Stdio has no headers, so Mcp-Param-* headers are never mirrored and the tools - // feature skips their validation. + // Stdio has no headers, so mirrored header validation is skipped. private static final McpTransportContext TRANSPORT_CONTEXT = McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)); + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED, + McpNameHeader.TRANSPORT_CONTEXT_KEY, McpNameHeader.NOT_MIRRORED)); private final McpRequestManager requestManager; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java index 126753d5f..5efa1c14f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/modern/server/transport/McpHeaderDecoderTests.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.server.transport.HeaderAccessor; import org.junit.jupiter.api.Test; @@ -87,6 +88,89 @@ void plainValueWithCharactersOutsideVisibleAsciiSpaceAndTabIsMalformed(String ra assertThat(McpHeaderDecoder.decodeValue(raw)).isNull(); } + @Test + void nameHeaderIsAbsentWhenMissing() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Method", "tools/call")); + + assertThat(header).isSameAs(McpNameHeader.ABSENT); + assertThat(header.isPresent()).isFalse(); + assertThat(header.value()).isNull(); + } + + @Test + void nameHeaderWithNoValuesIsMalformed() { + HeaderAccessor emptyValue = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return List.of(); + } + + @Override + public List getHeaderNames() { + return List.of("Mcp-Name"); + } + }; + + assertThat(McpHeaderDecoder.nameHeader(emptyValue)).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void nameHeaderIsReadIgnoringCase() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("mCP-NaMe", "private-name")); + + assertThat(header.isPresent()).isTrue(); + assertThat(header.isMalformed()).isFalse(); + assertThat(header.value()).isEqualTo("private-name"); + assertThat(header.toString()).doesNotContain("private-name"); + } + + @Test + void nameHeaderValueIsDecoded() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "=?base64?SGVsbG8=?=")); + + assertThat(header.value()).isEqualTo("Hello"); + } + + @Test + void malformedNameHeaderValueIsMarkedMalformed() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "=?base64?SGVsbG8?=")); + + assertThat(header).isSameAs(McpNameHeader.MALFORMED); + assertThat(header.isPresent()).isTrue(); + assertThat(header.value()).isNull(); + } + + @Test + void nameHeaderWithSeveralValuesIsMarkedMalformed() { + McpNameHeader header = McpHeaderDecoder.nameHeader(headers("Mcp-Name", "one", "Mcp-Name", "two")); + + assertThat(header).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void nameHeadersDifferingOnlyInCaseAreMarkedMalformed() { + Map> values = Map.of("Mcp-Name", List.of("one"), "mcp-name", List.of("two")); + HeaderAccessor caseSensitive = new HeaderAccessor() { + @Override + public List getHeader(String name) { + return values.getOrDefault(name, List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(values.keySet()); + } + }; + + assertThat(McpHeaderDecoder.nameHeader(caseSensitive)).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void notMirroredNameHeaderIsDistinctFromAbsent() { + assertThat(McpNameHeader.NOT_MIRRORED).isNotSameAs(McpNameHeader.ABSENT); + assertThat(McpNameHeader.NOT_MIRRORED.isPresent()).isFalse(); + } + @Test void paramHeadersSelectsMcpParamHeadersIgnoringThePrefixCase() { McpParamHeaders headers = McpHeaderDecoder.paramHeaders(headers("Mcp-Param-Region", "eu", "MCP-PARAM-Tenant", diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java index 68c56aefe..cd87a74c7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -38,6 +39,7 @@ import io.modelcontextprotocol.modern.McpSchema.Tool; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpAsyncResponse; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpServer; import io.modelcontextprotocol.modern.server.feature.McpAsyncToolRepository; @@ -94,6 +96,8 @@ class HttpServletMcpTransportIntegrationTests { private static final CountDownLatch LISTEN_CANCELLED = new CountDownLatch(1); + private static final AtomicReference LIST_NAME_HEADER = new AtomicReference<>(); + private static CallToolResult text(String text) { return CallToolResult.builder().addContent(TextContent.builder(text).build()).build(); } @@ -103,6 +107,7 @@ static void startServer() { McpAsyncToolRepository repo = new McpAsyncToolRepository() { @Override public Mono list(McpRequestContext ctx, String cursor) { + LIST_NAME_HEADER.set((McpNameHeader) ctx.transportContext().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)); return Mono.just(ToolsPage.of(List.of(ECHO_TOOL))); } @@ -152,7 +157,8 @@ private Mono> call(McpRequestContext ctx, Call .errorStatus(QUOTA_EXCEEDED, 429) .contextExtractor(request -> request.getHeader(FORGE_NOT_MIRRORED) != null ? McpTransportContext - .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED)) + .create(Map.of(McpParamHeaders.TRANSPORT_CONTEXT_KEY, McpParamHeaders.NOT_MIRRORED, + McpNameHeader.TRANSPORT_CONTEXT_KEY, McpNameHeader.NOT_MIRRORED)) : McpTransportContext.EMPTY) .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") @@ -414,6 +420,74 @@ void protocolVersionHeaderMismatchIsRejected() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void nameHeaderIsForwardedForAnUnnamedMethod() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("mcp-name", "task-123").build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get().value()).isEqualTo("task-123"); + assertThat(LIST_NAME_HEADER.get().toString()).doesNotContain("task-123"); + } + + @Test + void missingNameHeaderIsForwardedAsAbsent() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.ABSENT); + } + + @Test + void malformedNameHeaderIsForwardedWithoutAValue() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "=?base64?not*base64?=") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void repeatedNameHeaderIsForwardedAsMalformed() throws Exception { + LIST_NAME_HEADER.set(null); + byte[] body = JSON_MAPPER + .writeValueAsString( + Map.of("jsonrpc", "2.0", "id", 1, "method", "tools/list", "params", Map.of("_meta", meta()))) + .getBytes(StandardCharsets.UTF_8); + String headers = "POST " + ENDPOINT + " HTTP/1.0\r\n" + "Host: localhost:" + PORT + "\r\n" + + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Mcp-Method: tools/list\r\n" + "MCP-Protocol-Version: " + McpSchema.LATEST_PROTOCOL_VERSION + "\r\n" + + "Mcp-Name: first\r\n" + "mcp-name: second\r\n" + "Content-Length: " + body.length + "\r\n\r\n"; + String response; + try (Socket socket = new Socket("localhost", PORT)) { + socket.setSoTimeout(5000); + socket.getOutputStream().write(headers.getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().write(body); + socket.getOutputStream().flush(); + response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(response.substring(0, response.indexOf("\r\n"))).contains(" 200 "); + assertThat(LIST_NAME_HEADER.get()).isSameAs(McpNameHeader.MALFORMED); + } + + @Test + void contextExtractorCannotForgeNameHeader() throws Exception { + LIST_NAME_HEADER.set(null); + HttpRequest request = post("tools/list", Map.of("_meta", meta())).header("Mcp-Name", "task-123") + .header(FORGE_NOT_MIRRORED, "true") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(LIST_NAME_HEADER.get().value()).isEqualTo("task-123"); + } + @Test void missingProtocolVersionHeaderIsRejected() throws Exception { HttpRequest request = HttpRequest.newBuilder() @@ -478,6 +552,16 @@ void mcpNameIsCheckedAgainstTheUriOfAResourceRead() throws Exception { assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); } + @Test + void mcpNameIsStillCheckedAgainstTheNameOfAToolCall() throws Exception { + HttpRequest request = post("tools/call", Map.of("_meta", meta(), "name", "echo")).header("Mcp-Name", "other") + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + @Test void missingMcpNameIsRejectedForNonAsciiName() throws Exception { Map params = new HashMap<>(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java index 39ffa5ebf..595ef174c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java @@ -43,6 +43,7 @@ import io.modelcontextprotocol.modern.McpSchema.ErrorCodes; import io.modelcontextprotocol.modern.McpSchema.TextContent; import io.modelcontextprotocol.modern.McpSchema.Tool; +import io.modelcontextprotocol.modern.server.McpNameHeader; import io.modelcontextprotocol.modern.server.McpParamHeaders; import io.modelcontextprotocol.modern.server.McpRequestContext; import io.modelcontextprotocol.modern.server.McpRequestManager; @@ -534,7 +535,7 @@ void requestAfterCloseIsNotHandled() throws Exception { } @Test - void requestsAndNotificationsStateThatParamHeadersAreNotMirrored() throws Exception { + void requestsAndNotificationsStateThatHeadersAreNotMirrored() throws Exception { AtomicReference requestContext = new AtomicReference<>(); AtomicReference notificationContext = new AtomicReference<>(); start(new McpRequestManager() { @@ -567,6 +568,9 @@ public Mono handleNotification(McpTransportContext transportContext, .isSameAs(McpParamHeaders.NOT_MIRRORED); assertThat(notificationContext.get().get(McpParamHeaders.TRANSPORT_CONTEXT_KEY)) .isSameAs(McpParamHeaders.NOT_MIRRORED); + assertThat(requestContext.get().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)).isSameAs(McpNameHeader.NOT_MIRRORED); + assertThat(notificationContext.get().get(McpNameHeader.TRANSPORT_CONTEXT_KEY)) + .isSameAs(McpNameHeader.NOT_MIRRORED); } @Test