From bab2c77f488ce18fec0488390cfe55d735d42fa1 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 5 Nov 2026 11:05:21 -0800 Subject: [PATCH 1/2] feat: serve only MCP protocol 2026-07-28 --- pom.xml | 2 +- src/main/resources/application.yaml | 17 +- ...GeneralCommandToolsMcpIntegrationTest.java | 24 ++ .../McpFeatureDisabledIntegrationTest.java | 13 + .../api/v1/mcp/McpHttpTestSupport.java | 58 ++- .../api/v1/mcp/McpIntegrationTestBase.java | 21 + .../jsonapi/api/v1/mcp/McpProtocolTest.java | 358 +++++++++++++++++- .../v1/mcp/McpSetupErrorsIntegrationTest.java | 2 +- 8 files changed, 470 insertions(+), 25 deletions(-) diff --git a/pom.xml b/pom.xml index f4559e379d..9660c4c763 100644 --- a/pom.xml +++ b/pom.xml @@ -86,7 +86,7 @@ io.quarkiverse.mcp quarkus-mcp-server-bom - 2.0.3 + 2.1.0.Beta1 pom import diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b71db1339e..0c733d09b6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -222,6 +222,18 @@ quarkus: # MCP (Model Context Protocol) server configuration mcp: server: + # Admit and advertise only this revision. A stateless-only list also disables initialize, + # sessions and the legacy HTTP+SSE endpoints. + # https://docs.quarkiverse.io/quarkus-mcp-server/dev/concepts-transports.html + supported-protocol-versions: "2026-07-28" + # Results may depend on the caller's authorization, so keep them private and immediately + # stale until a cache policy is reviewed. + discover: + ttl-ms: 0 + cache-scope: private + tools: + ttl-ms: 0 + cache-scope: private http: root-path: /v1/mcp # McpOriginFilter applies the stargate.jsonapi.mcp.allowed-origins policy on every @@ -229,11 +241,6 @@ quarkus: # 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. - # https://docs.quarkiverse.io/quarkus-mcp-server/1.13.x/reference-configuration.html#quarkus-mcp-server-http_quarkus-mcp-server-http-streamable-auto-init - auto-init: true traffic-logging: # Never log raw MCP messages, even for diagnostics. enabled: false diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandToolsMcpIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandToolsMcpIntegrationTest.java index 7a94fcd73f..405aaccd52 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandToolsMcpIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandToolsMcpIntegrationTest.java @@ -84,6 +84,30 @@ void findEmbeddingProvidersToolCall() { })); } + @Test + void successfulToolCallIsTaggedCompleteOverRawHttp() { + var response = rawToolCall(1, CommandName.Names.FIND_KEYSPACES, Map.of()); + + var result = McpHttpTestSupport.assertResponseComplete(response, 1); + assertFalse(result.getBoolean("isError", false)); + assertNull(response.header("Mcp-Session-Id")); + assertNotNull(result.getJsonObject("_meta").getJsonObject(McpHttpTestSupport.SERVER_INFO)); + } + + @Test + void packagedServerDoesNotAdvertiseToolListChanges() { + // The tool list never changes at runtime, and subscriptions are not part of this launch. + var response = + rawRequest("server/discover") + .body(McpHttpTestSupport.rpcPayload(1, "server/discover", Map.of())) + .post(MCP_PATH); + + var result = McpHttpTestSupport.assertResponseComplete(response, 1); + var tools = result.getJsonObject("capabilities").getJsonObject("tools"); + assertNotNull(tools); + assertFalse(tools.containsKey("listChanged")); + } + @Test void findRerankingProvidersToolCall() { callToolAndAssert( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpFeatureDisabledIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpFeatureDisabledIntegrationTest.java index c2e2212f75..042d711541 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpFeatureDisabledIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpFeatureDisabledIntegrationTest.java @@ -58,10 +58,23 @@ public void failToolCallWithMCPDisabled() { })); } + @Test + public void toolErrorIsTaggedCompleteOverRawHttp() { + var response = rawToolCall(1, CommandName.Names.FIND_KEYSPACES, Map.of()); + + // One JSON body rather than an SSE stream, so there are no event IDs to resume. + assertThat(response.contentType()).startsWith("application/json"); + assertNull(response.header("Mcp-Session-Id")); + var result = McpHttpTestSupport.assertResponseComplete(response, 1); + assertTrue(result.getBoolean("isError")); + assertNotNull(result.getJsonObject("_meta").getJsonObject(McpHttpTestSupport.SERVER_INFO)); + } + @Test public void okToolCallWithHeaderEnableMCP() { var mcpClientWithHeaderEnable = McpAssured.newStreamableClient() + .setStateless() .setBaseUri(URI.create(MCP_HOSTNAME + getTestPort())) .setMcpPath(MCP_PATH) .setAdditionalHeaders(msg -> authHeaders().add(ApiFeature.MCP.httpHeaderName(), "true")) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java index 737b565a0c..2efe364f26 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java @@ -12,14 +12,18 @@ /** Shared wire format for raw HTTP tests that do not initialize an MCP client session. */ final class McpHttpTestSupport { - private static final String CURRENT_PROTOCOL_VERSION = "2026-07-28"; + static final String CURRENT_PROTOCOL_VERSION = "2026-07-28"; + static final String SERVER_INFO = "io.modelcontextprotocol/serverInfo"; private McpHttpTestSupport() {} + /** A POST with the Accept and Content-Type headers but none of the MCP request headers. */ + static RequestSpecification bareRequest() { + return given().contentType(ContentType.JSON).accept("application/json, text/event-stream"); + } + static RequestSpecification request(String method) { - return given() - .contentType(ContentType.JSON) - .accept("application/json, text/event-stream") + return bareRequest() .header("MCP-Protocol-Version", CURRENT_PROTOCOL_VERSION) .header("Mcp-Method", method); } @@ -29,17 +33,39 @@ static Map toolPayload(int id, String tool, Map } static Map rpcPayload(int id, String method, Map params) { + return rpcPayload(id, method, params, requestMeta(CURRENT_PROTOCOL_VERSION)); + } + + /** Builds a request with the given {@code _meta}, or without one when {@code meta} is null. */ + static Map rpcPayload( + int id, String method, Map params, Map meta) { var requestParams = new HashMap<>(params); - requestParams.put( - "_meta", - Map.of( - "io.modelcontextprotocol/protocolVersion", - CURRENT_PROTOCOL_VERSION, - "io.modelcontextprotocol/clientCapabilities", - Map.of())); + if (meta != null) { + requestParams.put("_meta", meta); + } return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", requestParams); } + /** A notification has no id, so the server must not answer it with a result. */ + static Map notificationPayload(String method) { + return Map.of( + "jsonrpc", + "2.0", + "method", + method, + "params", + Map.of("_meta", requestMeta(CURRENT_PROTOCOL_VERSION))); + } + + /** The required per-request fields, with clientInfo omitted because it is optional. */ + static Map requestMeta(String protocolVersion) { + return Map.of( + "io.modelcontextprotocol/protocolVersion", + protocolVersion, + "io.modelcontextprotocol/clientCapabilities", + Map.of()); + } + static JsonObject assertResponseComplete(Response response, int id) { assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); var body = new JsonObject(response.asString()); @@ -50,6 +76,16 @@ static JsonObject assertResponseComplete(Response response, int id) { return result; } + /** Asserts a JSON-RPC error envelope with the given HTTP status and error code. */ + static JsonObject assertRpcError(Response response, int httpStatus, int code) { + assertThat(response.statusCode()).as(response.asString()).isEqualTo(httpStatus); + var body = new JsonObject(response.asString()); + assertThat(body.containsKey("result")).isFalse(); + var error = body.getJsonObject("error"); + assertThat(error.getInteger("code")).as(error.encode()).isEqualTo(code); + return error; + } + static JsonObject assertResultReadable(JsonObject result) { var content = result.getJsonArray("content"); assertThat(content).hasSize(1); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java index 03a16ae209..b4983238fa 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java @@ -8,6 +8,8 @@ import io.quarkiverse.mcp.server.ToolResponse; import io.quarkiverse.mcp.server.test.McpAssured; import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; import io.stargate.sgv2.jsonapi.api.model.command.CommandName; import io.stargate.sgv2.jsonapi.config.constants.HttpConstants; import io.vertx.core.MultiMap; @@ -64,6 +66,8 @@ void setUpMcpClient() { mcpClient = McpAssured.newStreamableClient() + // The server admits only 2026-07-28, which has no initialize handshake or session. + .setStateless() .setBaseUri(URI.create(MCP_HOSTNAME + getTestPort())) .setMcpPath(MCP_PATH) .setAdditionalHeaders(msg -> authHeaders()) @@ -99,6 +103,23 @@ protected MultiMap authHeaders() { .add(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, credential); } + /** + * Starts a raw HTTP request with the same credentials, so a mistake shared by the test client and + * the server cannot hide a wire problem. + */ + protected RequestSpecification rawRequest(String method) { + var request = McpHttpTestSupport.request(method); + authHeaders().forEach(header -> request.header(header.getKey(), header.getValue())); + return request; + } + + protected Response rawToolCall(int id, String tool, Map arguments) { + return rawRequest("tools/call") + .header("Mcp-Name", tool) + .body(McpHttpTestSupport.toolPayload(id, tool, arguments)) + .post(MCP_PATH); + } + /** Create a keyspace via the MCP createKeyspace tool. */ protected void createKeyspace(String keyspace) { callToolAndAssert("createKeyspace", Map.of("name", keyspace), assertStatusOnlyOk()); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java index 75a989ad23..dc174abf34 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java @@ -1,34 +1,378 @@ package io.stargate.sgv2.jsonapi.api.v1.mcp; +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.CURRENT_PROTOCOL_VERSION; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.SERVER_INFO; import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertRpcError; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.bareRequest; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.notificationPayload; import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.requestMeta; import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.toolPayload; import static org.assertj.core.api.Assertions.assertThat; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import io.vertx.core.json.JsonObject; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; 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 HTTP wire contract without a client that might initialize a session for us. */ +/** + * Exercises the HTTP wire contract without a client that might initialize a session for us. + * + *

Expected statuses and codes follow the 2026-07-28 Streamable HTTP transport, versioning and + * caching sections of the MCP specification. No test here executes a tool. Other tests in this JVM + * reset the shared TenantFactory, so a tool result would depend on test order. Completed tool + * results are checked over raw HTTP in the MCP integration tests instead. + */ @QuarkusTest @TestProfile(NoGlobalResourcesTestProfile.Impl.class) class McpProtocolTest { + private static final String MCP_PATH = "/v1/mcp"; + private static final int HEADER_MISMATCH = -32020; + private static final int UNSUPPORTED_PROTOCOL_VERSION = -32022; + private static final int METHOD_NOT_FOUND = -32601; + private static final int INVALID_PARAMS = -32602; + private static final int PARSE_ERROR = -32700; + + // Header auth checks only that a token is present. Database requests validate it, but these + // requests never reach the database. + private static RequestSpecification authorized(RequestSpecification spec) { + return spec.header("Token", "protocol-test-credential"); + } + + private static RequestSpecification authorized(String method) { + return authorized(request(method)); + } + @Test void listsToolsWithoutInitializationOrSession() { // Each POST is independent. clientInfo, discovery and initialization are deliberately absent. for (int id = 1; id <= 2; id++) { var response = - request("tools/list") - // Header auth checks only that a token is present. Database requests validate it, - // but tools/list returns metadata without accessing the database. - .header("Token", "protocol-test-credential") - .body(rpcPayload(id, "tools/list", Map.of())) - .post("/v1/mcp"); + authorized("tools/list").body(rpcPayload(id, "tools/list", Map.of())).post(MCP_PATH); assertResponseComplete(response, id); assertThat(response.header("Mcp-Session-Id")).isNull(); assertThat(response.jsonPath().getList("result.tools.name")).isNotEmpty(); } } + + @Test + void discoverAdvertisesOnlyTheSupportedRevision() { + var response = + authorized("server/discover") + .body(rpcPayload(1, "server/discover", Map.of())) + .post(MCP_PATH); + + var result = assertResponseComplete(response, 1); + assertThat(result.getJsonArray("supportedVersions").getList()) + .containsExactly(CURRENT_PROTOCOL_VERSION); + assertPrivateAndImmediatelyStale(result); + assertThat(result.getJsonObject("_meta").getJsonObject(SERVER_INFO).getString("name")) + .isEqualTo("Stargate DATA API"); + + // quarkus-mcp-server 2.1.0.Beta1 always adds "logging", so it is not asserted here. Tool + // listChanged is checked against the packaged server in GeneralCommandToolsMcpIntegrationTest, + // because a test class here that injects ToolManager makes the framework advertise it. + var capabilities = result.getJsonObject("capabilities"); + assertThat(capabilities.fieldNames()) + .contains("tools") + .doesNotContain("prompts", "resources", "completions", "extensions", "tasks"); + } + + @Test + void toolsListCarriesCacheHintsAndServerInfo() { + var response = + authorized("tools/list").body(rpcPayload(1, "tools/list", Map.of())).post(MCP_PATH); + + var result = assertResponseComplete(response, 1); + assertPrivateAndImmediatelyStale(result); + assertThat(result.getJsonObject("_meta").getJsonObject(SERVER_INFO).getString("name")) + .isEqualTo("Stargate DATA API"); + } + + @Test + void toolsListOrderIsStableAcrossRequestsAndPages() { + var first = listAllToolNames(); + var second = listAllToolNames(); + + assertThat(first).isNotEmpty().doesNotHaveDuplicates(); + assertThat(second).containsExactlyElementsOf(first); + } + + @ParameterizedTest + @ValueSource(strings = {"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2099-01-01"}) + void rejectsUnsupportedRevisions(String version) { + var response = + authorized(bareRequest()) + .header("MCP-Protocol-Version", version) + .header("Mcp-Method", "tools/list") + .body(rpcPayload(1, "tools/list", Map.of(), requestMeta(version))) + .post(MCP_PATH); + + var error = assertRpcError(response, 400, UNSUPPORTED_PROTOCOL_VERSION); + assertThat(error.getJsonObject("data").getJsonArray("supported").getList()) + .containsExactly(CURRENT_PROTOCOL_VERSION); + assertThat(error.getJsonObject("data").getString("requested")).isEqualTo(version); + } + + @Test + void rejectsMissingProtocolVersionHeader() { + var response = + authorized(bareRequest()) + .header("Mcp-Method", "tools/list") + .body(rpcPayload(1, "tools/list", Map.of())) + .post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void rejectsProtocolVersionHeaderThatDiffersFromMeta() { + var response = + authorized("tools/list") + .body(rpcPayload(1, "tools/list", Map.of(), requestMeta("2025-11-25"))) + .post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void rejectsMissingMcpMethodHeader() { + var response = + authorized(bareRequest()) + .header("MCP-Protocol-Version", CURRENT_PROTOCOL_VERSION) + .body(rpcPayload(1, "tools/list", Map.of())) + .post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void rejectsMcpMethodHeaderThatDiffersFromBody() { + var response = + authorized("server/discover").body(rpcPayload(1, "tools/list", Map.of())).post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void rejectsToolCallWithoutMcpName() { + var response = + authorized("tools/call").body(toolPayload(1, "findKeyspaces", Map.of())).post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void rejectsMcpNameThatDiffersFromBody() { + var response = + authorized("tools/call") + .header("Mcp-Name", "findCollections") + .body(toolPayload(1, "findKeyspaces", Map.of())) + .post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + } + + @Test + void acceptsHeaderNamesInAnyCase() { + var response = + authorized(bareRequest()) + .header("mcp-protocol-version", CURRENT_PROTOCOL_VERSION) + .header("MCP-METHOD", "tools/list") + .body(rpcPayload(1, "tools/list", Map.of())) + .post(MCP_PATH); + + assertResponseComplete(response, 1); + } + + @Test + void rejectsRequestWithoutMeta() { + var response = + authorized("tools/list").body(rpcPayload(1, "tools/list", Map.of(), null)).post(MCP_PATH); + + assertRpcError(response, 400, INVALID_PARAMS); + } + + @ParameterizedTest + @ValueSource( + strings = { + "io.modelcontextprotocol/protocolVersion", + "io.modelcontextprotocol/clientCapabilities" + }) + void rejectsMetaWithoutRequiredField(String field) { + var meta = new HashMap<>(requestMeta(CURRENT_PROTOCOL_VERSION)); + meta.remove(field); + var response = + authorized("tools/list").body(rpcPayload(1, "tools/list", Map.of(), meta)).post(MCP_PATH); + + assertRpcError(response, 400, INVALID_PARAMS); + } + + @Test + void rejectsClientInfoThatIsNotAnObject() { + var meta = new HashMap<>(requestMeta(CURRENT_PROTOCOL_VERSION)); + meta.put("io.modelcontextprotocol/clientInfo", "protocol-test"); + var response = + authorized("tools/list").body(rpcPayload(1, "tools/list", Map.of(), meta)).post(MCP_PATH); + + assertRpcError(response, 400, INVALID_PARAMS); + } + + @Test + void rejectsInitializeWithoutCreatingSession() { + var params = + Map.of( + "protocolVersion", + CURRENT_PROTOCOL_VERSION, + "capabilities", + Map.of(), + "clientInfo", + Map.of("name", "protocol-test", "version", "1")); + var response = + authorized("initialize").body(rpcPayload(1, "initialize", params)).post(MCP_PATH); + + assertRpcError(response, 404, METHOD_NOT_FOUND); + assertThat(response.header("Mcp-Session-Id")).isNull(); + } + + @Test + void rejectsLegacyInitializeHandshake() { + // A 2025-06-18 client sends initialize with no MCP-Protocol-Version header or _meta. + var legacy = + Map.of( + "jsonrpc", + "2.0", + "id", + 1, + "method", + "initialize", + "params", + Map.of( + "protocolVersion", + "2025-06-18", + "capabilities", + Map.of(), + "clientInfo", + Map.of("name", "protocol-test", "version", "1"))); + var response = authorized(bareRequest()).body(legacy).post(MCP_PATH); + + assertRpcError(response, 400, HEADER_MISMATCH); + assertThat(response.header("Mcp-Session-Id")).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"ping", "logging/setLevel", "no/such/method"}) + void rejectsRemovedAndUnknownMethods(String method) { + var params = + method.equals("logging/setLevel") + ? Map.of("level", "debug") + : Map.of(); + var response = authorized(method).body(rpcPayload(1, method, params)).post(MCP_PATH); + + assertRpcError(response, 404, METHOD_NOT_FOUND); + } + + @ParameterizedTest + @ValueSource(strings = {"notifications/initialized", "notifications/roots/list_changed"}) + void rejectsRemovedNotifications(String method) { + var response = authorized(method).body(notificationPayload(method)).post(MCP_PATH); + + assertRpcError(response, 404, METHOD_NOT_FOUND); + } + + @Test + void rejectsUnknownTool() { + var response = + authorized("tools/call") + .header("Mcp-Name", "noSuchTool") + .body(toolPayload(1, "noSuchTool", Map.of())) + .post(MCP_PATH); + + assertThat(new JsonObject(response.asString()).getJsonObject("error").getInteger("code")) + .isEqualTo(INVALID_PARAMS); + } + + @Test + void reportsMalformedJsonAsParseError() { + var response = authorized("tools/list").body("{not json").post(MCP_PATH); + + // The HTTP status is not asserted. The framework answers 200, and the transport section does + // not name a status for a malformed request body. + var error = new JsonObject(response.asString()).getJsonObject("error"); + assertThat(error.getInteger("code")).isEqualTo(PARSE_ERROR); + } + + @Test + void ignoresObsoleteSessionAndResumptionHeaders() { + var response = + authorized("tools/list") + .header("Mcp-Session-Id", "obsolete-session") + .header("Last-Event-ID", "42") + .body(rpcPayload(1, "tools/list", Map.of())) + .post(MCP_PATH); + + assertResponseComplete(response, 1); + assertThat(response.contentType()).startsWith("application/json"); + assertThat(response.header("Mcp-Session-Id")).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "DELETE"}) + void rejectsNonPostMethodsOnTheEndpoint(String httpMethod) { + // With and without the version header, as an older client might send either. + for (var spec : + List.of(given(), given().header("MCP-Protocol-Version", CURRENT_PROTOCOL_VERSION))) { + Response response = authorized(spec).request(httpMethod, MCP_PATH); + assertThat(response.statusCode()).isEqualTo(405); + assertThat(response.header("Allow")).isEqualTo("POST"); + } + } + + @Test + void legacyHttpSseRoutesAreRemoved() { + assertThat(authorized(given()).get(MCP_PATH + "/sse").statusCode()).isEqualTo(404); + assertThat( + authorized(given()) + .contentType("application/json") + .body("{}") + .post(MCP_PATH + "/messages/obsolete-session") + .statusCode()) + .isEqualTo(404); + } + + private static void assertPrivateAndImmediatelyStale(JsonObject result) { + // ttlMs must be an integer, not a decimal or string. + assertThat(result.getValue("ttlMs")).isInstanceOf(Integer.class).isEqualTo(0); + assertThat(result.getString("cacheScope")).isEqualTo("private"); + } + + private static List listAllToolNames() { + var names = new ArrayList(); + String cursor = null; + int id = 1; + do { + var params = + cursor == null ? Map.of() : Map.of("cursor", cursor); + var response = + authorized("tools/list").body(rpcPayload(id, "tools/list", params)).post(MCP_PATH); + var result = assertResponseComplete(response, id++); + result + .getJsonArray("tools") + .forEach(tool -> names.add(((JsonObject) tool).getString("name"))); + cursor = result.getString("nextCursor"); + } while (cursor != null); + return names; + } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java index 3e00c143b0..e21cf0ff94 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java @@ -106,7 +106,7 @@ void malformedJsonRemainsAProtocolError() { .body("{invalid-json") .post(MCP_PATH); - // Quarkiverse 2.0.3 reports parse errors in a JSON-RPC envelope with HTTP 200. + // The MCP server reports parse errors in a JSON-RPC envelope with HTTP 200. assertProtocolError(response, null, JsonRpcErrorCodes.PARSE_ERROR); assertProviderDiscoverySucceeds(7); } From 9c7cf6e160c714f7cdc8a3a1e2d0975d6b78270a Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 5 Nov 2026 21:21:43 +0000 Subject: [PATCH 2/2] test: expect HTTP 404 for unknown MCP methods under protocol 2026-07-28 --- .../api/v1/mcp/McpSetupErrorsIntegrationTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java index e21cf0ff94..6bae1276be 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSetupErrorsIntegrationTest.java @@ -116,7 +116,8 @@ void unknownMethodAndToolRemainProtocolErrors() { String method = "tools/unknownMethod"; var response = request(method, null, true).body(rpcPayload(8, method, Map.of())).post(MCP_PATH); - assertProtocolError(response, 8, JsonRpcErrorCodes.METHOD_NOT_FOUND); + // Protocol 2026-07-28 answers an unknown method with HTTP 404 and a JSON-RPC error body. + assertProtocolError(response, 404, 8, JsonRpcErrorCodes.METHOD_NOT_FOUND); assertProtocolError(callTool(9, "unknownTool", Map.of()), 9, JsonRpcErrorCodes.INVALID_PARAMS); assertProviderDiscoverySucceeds(10); } @@ -160,7 +161,12 @@ private static void assertApplicationError(Response response, int id, ErrorCode< } private static void assertProtocolError(Response response, Integer id, int errorCode) { - assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); + assertProtocolError(response, 200, id, errorCode); + } + + private static void assertProtocolError( + Response response, int httpStatus, Integer id, int errorCode) { + assertThat(response.statusCode()).as(response.asString()).isEqualTo(httpStatus); var body = new JsonObject(response.asString()); assertThat(body.getInteger("id")).isEqualTo(id); assertThat(body.containsKey("result")).isFalse();