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");
+ }
+}