From b94c89dc74c24805adb19da98e7663d7ca00f032 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 5 Nov 2026 11:05:20 -0800 Subject: [PATCH] fix: reject MCP requests from unapproved browser origins --- CONFIGURATION.md | 15 ++ .../jsonapi/api/v1/mcp/McpOriginFilter.java | 173 ++++++++++++++ src/main/resources/application.yaml | 5 + .../v1/mcp/McpOriginDefaultPolicyTest.java | 52 ++++ .../api/v1/mcp/McpOriginFilterTest.java | 73 ++++++ .../api/v1/mcp/McpOriginPolicyTest.java | 225 ++++++++++++++++++ 6 files changed, 543 insertions(+) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilter.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginDefaultPolicyTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilterTest.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginPolicyTest.java diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 257ace4d34..448c762cfc 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -115,3 +115,18 @@ that service is integrated. | Property | Type | Default | Description | |---------------------------------|-----------|---------|---------------------------------------------------------------------------------------------------------------------| | `stargate.feature.flags.tables` | `boolean` | `true` (enabled by default) | Setting it to `true` enables Tables functionality; `false` disables; leaving as `null` uses the default (enabled).| + +## MCP browser Origin configuration +*Configuration for browser access to the MCP endpoint, enforced by [McpOriginFilter.java](src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilter.java).* + +| Property | Type | Default | Description | +|----------------------------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `stargate.jsonapi.mcp.allowed-origins` | `list` | empty | Comma separated browser Origins that may call the MCP endpoint, written exactly as browsers send them, such as `https://app.example.com`. Empty rejects every request that has an Origin header. | + +Requests without an `Origin` header come from native clients and are not affected. A request with +any other `Origin`, including a malformed value or more than one `Origin` header, receives HTTP 403 +with a fixed JSON-RPC error body before authentication runs. Approved Origins can send a CORS +preflight without credentials and can read the `WWW-Authenticate` challenge. Browser access allows +POST only, with the `Authorization`, `Content-Type`, `MCP-Protocol-Version`, `Mcp-Method` and +`Mcp-Name` request headers. Origin approval never replaces authentication. Entries must use a lowercase `http` or `https` scheme and host, no path +and no default port. Any other entry stops startup. Wildcards are not supported. diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilter.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilter.java new file mode 100644 index 0000000000..0d7fb853ff --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilter.java @@ -0,0 +1,173 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import io.quarkus.vertx.http.runtime.filters.Filters; +import io.quarkus.vertx.http.runtime.security.SecurityHandlerPriorities; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * Rejects MCP requests from browser Origins that are not explicitly approved, as the MCP Streamable + * HTTP transport requires. Requests without an Origin header come from native clients and pass + * through unchanged. + * + *

The check is a Quarkus HTTP filter that runs before CORS, authentication and authorization. A + * rejected request never reaches authentication or a tool, and an approved browser can complete its + * CORS preflight without credentials. OAuth still decides what each caller may do. + */ +@ApplicationScoped +public class McpOriginFilter { + static final String ALLOWED_ORIGINS = "stargate.jsonapi.mcp.allowed-origins"; + + // Filters run in descending priority, so this runs before CORS (300), authentication (200) and + // authorization (100), and after host validation (400). + static final int PRIORITY = SecurityHandlerPriorities.CORS + 50; + + static final String ALLOWED_METHODS = "POST"; + static final String ALLOWED_HEADERS = + "Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name"; + // Lets an approved browser read the OAuth challenge on a 401 or 403 response. + static final String EXPOSED_HEADERS = "WWW-Authenticate"; + + // The request is rejected before its id is read, so JSON-RPC requires a null id. + private static final String REJECTION_BODY = + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"Origin not allowed\"}}"; + + private final String endpointPath; + private final Set allowedOrigins; + + @Inject + public McpOriginFilter( + @ConfigProperty(name = "quarkus.http.root-path", defaultValue = "/") String httpRootPath, + @ConfigProperty(name = "quarkus.mcp.server.http.root-path", defaultValue = "/mcp") + String mcpRootPath, + @ConfigProperty(name = ALLOWED_ORIGINS) Optional> allowedOrigins) { + this.endpointPath = endpointPath(httpRootPath, mcpRootPath); + this.allowedOrigins = approvedOrigins(allowedOrigins.orElse(List.of())); + } + + void register(@Observes Filters filters) { + filters.register(this::filter, PRIORITY); + } + + void filter(RoutingContext context) { + if (!isMcpPath(context.normalizedPath())) { + context.next(); + return; + } + HttpServerRequest request = context.request(); + HttpServerResponse response = context.response(); + // Set at write time so error handlers that rebuild the response cannot drop the headers. + context.addHeadersEndHandler(v -> response.headers().add(HttpHeaders.VARY, "Origin")); + + List origins = request.headers().getAll(HttpHeaders.ORIGIN); + if (origins.isEmpty()) { + context.next(); + return; + } + String origin = origins.get(0); + if (origins.size() > 1 || !allowedOrigins.contains(origin)) { + response + .setStatusCode(403) + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(REJECTION_BODY); + return; + } + + if (isPreflight(request)) { + response + .setStatusCode(204) + .putHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin) + .putHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, ALLOWED_METHODS) + .putHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_HEADERS) + .end(); + return; + } + context.addHeadersEndHandler( + v -> + response + .putHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin) + .putHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, EXPOSED_HEADERS)); + context.next(); + } + + boolean isMcpPath(String path) { + return path != null + && (endpointPath.equals("/") + || path.equals(endpointPath) + || path.startsWith(endpointPath + "/")); + } + + private static boolean isPreflight(HttpServerRequest request) { + return HttpMethod.OPTIONS.equals(request.method()) + && request.headers().contains(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD); + } + + /** + * Resolves the MCP root path against the HTTP root path the same way Quarkus resolves the routes + * that the MCP extension registers, so the filter covers the endpoint and every path below it. + */ + static String endpointPath(String httpRootPath, String mcpRootPath) { + String base = httpRootPath.endsWith("/") ? httpRootPath : httpRootPath + "/"; + String relative = mcpRootPath.startsWith("/") ? mcpRootPath.substring(1) : mcpRootPath; + String path = URI.create(base).resolve(relative).normalize().getPath(); + return path.length() > 1 && path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + /** + * Validates configured Origins at startup. Each entry must be written exactly as a browser sends + * it, so that request matching can be an exact string comparison with no normalization. + */ + static Set approvedOrigins(List configured) { + for (String origin : configured) { + if (!origin.equals(serializedOrigin(origin))) { + throw new IllegalArgumentException( + "Invalid " + + ALLOWED_ORIGINS + + " entry '" + + origin + + "'. Use the exact browser Origin, such as https://app.example.com, with a" + + " lowercase http or https scheme and host, no path and no default port."); + } + } + return Set.copyOf(configured); + } + + /** Returns the browser serialization of an http or https origin, or null for anything else. */ + static String serializedOrigin(String value) { + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException e) { + return null; + } + String scheme = uri.getScheme(); + if (!("http".equals(scheme) || "https".equals(scheme)) + || uri.isOpaque() + || uri.getHost() == null + || uri.getRawUserInfo() != null + || !uri.getRawPath().isEmpty() + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + return null; + } + int port = uri.getPort(); + boolean defaultPort = port == -1 || port == ("https".equals(scheme) ? 443 : 80); + return scheme + + "://" + + uri.getHost().toLowerCase(Locale.ROOT) + + (defaultPort ? "" : ":" + port); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9ca7711e55..b71db1339e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -224,6 +224,11 @@ quarkus: server: http: root-path: /v1/mcp + # McpOriginFilter applies the stargate.jsonapi.mcp.allowed-origins policy on every + # binding. The framework check only runs for localhost bindings and would reject + # approved non-local Origins there. + dns-rebinding-check: + enabled: false streamable: # It performs an automatic initialization when the first message from the client is not initialize. # A new MCP session is created for each request. diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginDefaultPolicyTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginDefaultPolicyTest.java new file mode 100644 index 0000000000..2aa73383f3 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginDefaultPolicyTest.java @@ -0,0 +1,52 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpOriginPolicyTest.assertRejected; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** With no approved Origins configured, every browser request to the MCP endpoint is rejected. */ +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class McpOriginDefaultPolicyTest { + + @ParameterizedTest + // Local Origins are rejected too. The default does not trust localhost. + @ValueSource(strings = {"https://client.example.com", "http://localhost:6274", "null"}) + void rejectsEveryBrowserOrigin(String origin) { + assertRejected( + request("tools/list") + .header("Token", "origin-test-credential") + .header("Origin", origin) + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp")); + } + + @Test + void rejectsEveryPreflight() { + assertRejected( + given() + .header("Origin", "http://localhost:6274") + .header("Access-Control-Request-Method", "POST") + .options("/v1/mcp")); + } + + @Test + void allowsNativeClientsWithoutOrigin() { + assertResponseComplete( + request("tools/list") + .header("Token", "origin-test-credential") + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp"), + 1); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilterTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilterTest.java new file mode 100644 index 0000000000..e1de9bcf50 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginFilterTest.java @@ -0,0 +1,73 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Covers Origin configuration and path matching without starting the application. */ +class McpOriginFilterTest { + + @Test + void acceptsExactBrowserOrigins() { + var origins = List.of("https://app.example.com", "http://localhost:6274", "http://[::1]:8080"); + assertThat(McpOriginFilter.approvedOrigins(origins)) + .containsExactlyInAnyOrderElementsOf(origins); + } + + @ParameterizedTest + @ValueSource( + strings = { + "*", + "null", + "app.example.com", + "https://app.example.com/", + "https://app.example.com/path", + "https://app.example.com?query", + "https://app.example.com#fragment", + "https://user@app.example.com", + "https://APP.example.com", + "HTTPS://app.example.com", + "https://app.example.com:443", + "http://app.example.com:80", + "ftp://app.example.com", + "https://*.example.com", + "https://app.example.com, https://other.example.com", + "not an origin" + }) + void rejectsConfiguredValuesThatBrowsersNeverSend(String origin) { + assertThatThrownBy(() -> McpOriginFilter.approvedOrigins(List.of(origin))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(McpOriginFilter.ALLOWED_ORIGINS); + } + + @ParameterizedTest + @CsvSource({ + "/, /v1/mcp, /v1/mcp", + "/, v1/mcp, /v1/mcp", + "/, /v1/mcp/, /v1/mcp", + "/api, /v1/mcp, /api/v1/mcp", + "/api/, v1/mcp, /api/v1/mcp", + "/, /, /" + }) + void resolvesTheEndpointLikeQuarkusRoutes(String httpRoot, String mcpRoot, String expected) { + assertThat(McpOriginFilter.endpointPath(httpRoot, mcpRoot)).isEqualTo(expected); + } + + @Test + void matchesTheEndpointAndPathsBelowIt() { + var filter = new McpOriginFilter("/", "/v1/mcp", Optional.empty()); + assertThat(filter.isMcpPath("/v1/mcp")).isTrue(); + assertThat(filter.isMcpPath("/v1/mcp/")).isTrue(); + assertThat(filter.isMcpPath("/v1/mcp/sse")).isTrue(); + // A keyspace whose name starts with "mcp" belongs to the ordinary HTTP API. + assertThat(filter.isMcpPath("/v1/mcpkeyspace")).isFalse(); + assertThat(filter.isMcpPath("/v1")).isFalse(); + assertThat(filter.isMcpPath(null)).isFalse(); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginPolicyTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginPolicyTest.java new file mode 100644 index 0000000000..e596acbfdd --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOriginPolicyTest.java @@ -0,0 +1,225 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; +import static org.assertj.core.api.Assertions.assertThat; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import io.vertx.core.json.JsonObject; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Exercises the browser Origin policy at the MCP endpoint with an approved Origin configured. */ +@QuarkusTest +@TestProfile(McpOriginPolicyTest.Profile.class) +class McpOriginPolicyTest { + private static final String EXPECTED_REJECTION_BODY = + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"Origin not allowed\"}}"; + static final String APPROVED = "https://client.example.com"; + static final String LOCAL_APPROVED = "http://localhost:6274"; + // Header auth checks only that a token is present. tools/list does not access the database. + static final String TOKEN = "origin-test-credential"; + + public static class Profile implements NoGlobalResourcesTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of(McpOriginFilter.ALLOWED_ORIGINS, APPROVED + "," + LOCAL_APPROVED); + } + } + + @Test + void allowsNativeClientsWithoutOrigin() { + var response = listTools(null); + assertResponseComplete(response, 1); + assertThat(response.header("Access-Control-Allow-Origin")).isNull(); + assertThat(response.headers().getValues("Vary")).contains("Origin"); + } + + @ParameterizedTest + @ValueSource(strings = {APPROVED, LOCAL_APPROVED}) + void allowsApprovedOrigins(String origin) { + var response = listTools(origin); + assertResponseComplete(response, 1); + assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo(origin); + assertThat(response.header("Access-Control-Expose-Headers")).isEqualTo("WWW-Authenticate"); + assertThat(response.header("Access-Control-Allow-Credentials")).isNull(); + assertThat(response.headers().getValues("Vary")).contains("Origin"); + } + + @Test + void approvedOriginStillRequiresCredentials() { + var response = + request("tools/list") + .header("Origin", APPROVED) + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp"); + assertThat(response.statusCode()).isEqualTo(401); + // The browser can read the 401. WWW-Authenticate is exposed now so the OAuth challenge from + // #2642 is readable once it is added. + assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo(APPROVED); + assertThat(response.header("Access-Control-Expose-Headers")).isEqualTo("WWW-Authenticate"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "https://client.example.com.evil.example", + "https://evilclient.example.com", + "https://evil.example", + "http://client.example.com", + "https://client.example.com:8443", + "https://client.example.com:443", + "https://client.example.com/", + "https://client.example.com/path", + "https://CLIENT.example.com", + "https://user@client.example.com", + "https://client.example.com, https://evil.example", + "http://localhost", + "http://127.0.0.1:6274", + "null", + "*", + "not an origin", + "" + }) + void rejectsUnapprovedAndMalformedOrigins(String origin) { + assertRejected(listTools(origin)); + } + + @Test + void rejectsMoreThanOneOriginHeader() { + assertRejected( + request("tools/list") + .header("Token", TOKEN) + .header("Origin", APPROVED) + .header("Origin", "https://evil.example") + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp")); + } + + @Test + void rejectsBeforeAuthentication() { + // 403 rather than 401 shows that authentication did not run for the rejected request. + assertRejected( + request("tools/list") + .header("Origin", "https://evil.example") + .body(rpcPayload(1, "tools/list", Map.of())) + .post("/v1/mcp")); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "DELETE"}) + void rejectsOtherMethods(String method) { + assertRejected( + request("tools/list") + .header("Token", TOKEN) + .header("Origin", "https://evil.example") + .request(method, "/v1/mcp")); + } + + @ParameterizedTest + @ValueSource(strings = {"/v1/mcp", "/v1/mcp/"}) + void answersPreflightFromApprovedOriginWithoutCredentials(String path) { + var response = preflight(APPROVED, path); + assertThat(response.statusCode()).isEqualTo(204); + assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo(APPROVED); + assertThat(response.header("Access-Control-Allow-Methods")).isEqualTo("POST"); + assertThat(response.header("Access-Control-Allow-Headers")) + .isEqualTo("Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name"); + assertThat(response.header("Access-Control-Allow-Credentials")).isNull(); + assertThat(response.headers().getValues("Vary")).contains("Origin"); + assertThat(response.asString()).isEmpty(); + } + + @Test + void rejectsPreflightFromUnapprovedOrigin() { + assertRejected(preflight("https://client.example.com.evil.example", "/v1/mcp")); + } + + @Test + void treatsOptionsWithoutRequestMethodAsAnOrdinaryRequest() { + // Not a CORS preflight, so it continues to authentication instead of getting a 204. + var response = given().header("Origin", APPROVED).options("/v1/mcp"); + assertThat(response.statusCode()).isEqualTo(401); + assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo(APPROVED); + assertThat(response.header("Access-Control-Allow-Methods")).isNull(); + } + + @ParameterizedTest + // Vert.x routes on the normalized path, so the filter must match these the same way. The JDK + // client sends the path exactly as written, unlike RestAssured. + @ValueSource(strings = {"/v1/mcp/", "/v1/%6Dcp", "/v1/./mcp", "/v1/x/../mcp", "/v1/mcp/sse"}) + void rejectsEveryPathThatReachesTheEndpoint(String path) throws Exception { + var request = + HttpRequest.newBuilder(URI.create("http://localhost:" + RestAssured.port + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .header("Token", TOKEN) + .header("Origin", "https://evil.example") + .POST( + HttpRequest.BodyPublishers.ofString( + new JsonObject(rpcPayload(1, "tools/list", Map.of())).encode())) + .build(); + try (var client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) { + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(path).isEqualTo(403); + assertThat(response.body()).as(path).isEqualTo(EXPECTED_REJECTION_BODY); + } + } + + @Test + void leavesTheOrdinaryHttpApiAlone() { + // The ordinary HTTP API keeps its own policy. These reach authentication and fail there. + for (String path : new String[] {"/v1", "/v1/mcpkeyspace"}) { + var response = + given() + .contentType("application/json") + .header("Origin", "https://evil.example") + .body(Map.of("findKeyspaces", Map.of())) + .post(path); + assertThat(response.statusCode()).as(path).isEqualTo(401); + assertThat(response.header("Access-Control-Allow-Origin")).as(path).isNull(); + } + } + + private static Response listTools(String origin) { + var spec = request("tools/list").header("Token", TOKEN); + if (origin != null) { + spec = spec.header("Origin", origin); + } + return spec.body(rpcPayload(1, "tools/list", Map.of())).post("/v1/mcp"); + } + + private static Response preflight(String origin, String path) { + return given() + .header("Origin", origin) + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "authorization, content-type, mcp-method, mcp-name, mcp-protocol-version") + .options(path); + } + + static void assertRejected(Response response) { + assertThat(response.statusCode()).as(response.asString()).isEqualTo(403); + assertThat(response.contentType()).startsWith("application/json"); + // A fixed body, so neither the request payload nor its credentials can appear in it. + assertThat(response.asString()).isEqualTo(EXPECTED_REJECTION_BODY); + assertThat(response.header("Access-Control-Allow-Origin")).isNull(); + assertThat(response.header("Access-Control-Allow-Methods")).isNull(); + assertThat(response.headers().getValues("Vary")).contains("Origin"); + } +}