Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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<List<String>> 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<String> 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<String> approvedOrigins(List<String> 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);
}
}
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading