From 13ac02c44dab578b5587a8cc3924d70dbe113209 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 13 Nov 2026 11:02:55 +0100 Subject: [PATCH] Reject `initialize` requests with UnsupportedProtocolVersion Signed-off-by: Daniel Garnier-Moiroux --- .../modern/McpSchema.java | 9 +++ .../modern/server/McpServer.java | 42 ++++++++++++++ .../transport/HttpServletMcpTransport.java | 12 ++++ .../modern/server/McpServerTests.java | 52 +++++++++++++++++ ...tpServletMcpTransportIntegrationTests.java | 58 +++++++++++++++++++ .../transport/StdioMcpTransportTests.java | 26 +++++++++ 6 files changed, 199 insertions(+) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java index feeee1327..e9b3471d7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java @@ -53,6 +53,15 @@ private McpSchema() { public static final String METHOD_SERVER_DISCOVER = "server/discover"; + /** + * The handshake that opens a connection in the initialization-based (legacy) protocol + * versions. + * @deprecated This is only used to recognize legacy client requests so they can be + * rejected with an appropriate error message. + */ + @Deprecated + public static final String METHOD_INITIALIZE = "initialize"; + public static final String METHOD_TOOLS_LIST = "tools/list"; public static final String METHOD_TOOLS_CALL = "tools/call"; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java index 5c13221f9..b688c1938 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -133,6 +133,14 @@ private Mono dispatch(McpTransportContext transportContext return invalidParams(id, "params is malformed"); } Map meta = asMetaMap(paramsMap.get("_meta")); + if (McpSchema.METHOD_INITIALIZE.equals(request.method()) && !hasModernVersionClaim(meta)) { + // Legacy client: tries to initialize and doesn't pass version header. We + // reject early to return the correct error and not a header-validation error. + // If there is a modern version header, it's an invalid INITIALIZE request + // from a modern client: let it fall through, it will be rejected with + // METHOD_NOT_FOUND + return rejectLegacyInitialize(id, paramsMap); + } if (meta == null) { return invalidParams(id, "params._meta is required"); } @@ -308,6 +316,40 @@ private static JSONRPCResponse bug(McpRequestContext ctx, String problem) { return JSONRPCResponse.error(ctx.requestId(), new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error")); } + /** + * Answers the {@code initialize} handshake of a client on an initialization-based + * (legacy) protocol version. The server broadcasts to name its supported version(s) + * in the error response. + *

+ * This runs before any {@code params}/{@code _meta} validation: a legacy + * {@code initialize} carries neither, and would otherwise be answered with an error + * about the missing {@code _meta} instead. + * + * @see Protocol + * Version Negotiation + */ + private Mono rejectLegacyInitialize(Object id, Map params) { + // The legacy handshake names the client's version in params.protocolVersion. When + // it is missing, "requested" is sent as an empty string: the schema requires the + // field, and an empty value doesn't pretend the client asked for any version. + String requested = params.get("protocolVersion") instanceof String version ? version : ""; + String message = "Unsupported protocol version" + (requested.isEmpty() ? "" : " " + requested) + + ": this server does not support the initialize handshake; supported protocol versions: " + + String.join(", ", this.supportedVersions); + return error(id, new JSONRPCError(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION, message, + new UnsupportedProtocolVersionData(this.supportedVersions, requested))); + } + + /** + * Whether {@code params._meta} names a protocol version, i.e. the request claims to + * follow a per-request-metadata revision rather than the legacy handshake. + */ + private static boolean hasModernVersionClaim(Map meta) { + return meta != null && meta.get(MetaKeys.PROTOCOL_VERSION) instanceof String protocolVersion + && !protocolVersion.isBlank(); + } + private static Mono error(Object id, JSONRPCError error) { return Mono.just(McpTransportResponse.error(JSONRPCResponse.error(id, error))); } 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 071825910..c746cd77c 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 @@ -413,6 +413,18 @@ private void writeJson(HttpServletResponse response, int status, JSONRPCResponse * consistent with the body */ private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRpcRequest) { + String metaVersion = protocolVersionFromMeta(jsonRpcRequest.params()); + if (McpSchema.METHOD_INITIALIZE.equals(jsonRpcRequest.method()) + && (metaVersion == null || metaVersion.isBlank())) { + // A client on an initialization-based (legacy) version opens with initialize + // and sends none of these headers. Its request is left to the request + // manager, which knows the supported versions and names them in its + // rejection: answering "Missing required header" would leave that client with + // nothing actionable. Fall through to the server validation. A blank _meta + // version names no revision, so McpServer treats it as legacy too; it must be + // waived here as well, or it would fail on the headers first. + return null; + } List methodHeaders = Collections.list(request.getHeaders(Headers.MCP_METHOD)); if (methodHeaders.isEmpty()) { return "Missing required header: Mcp-Method"; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java index d174b1acd..f921d6ce2 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java @@ -108,6 +108,58 @@ void unsupportedVersionWinsOverMissingClientCapabilities() { .verifyComplete(); } + @Test + void legacyInitializeNamesSupportedVersions() { + McpServer server = baseBuilder().supportedVersions(List.of("2026-07-28", "2026-12-01")).build(); + Map params = Map.of("protocolVersion", "2025-11-25", "capabilities", Map.of(), "clientInfo", + Map.of("name", "legacy-client", "version", "1.0.0")); + + JSONRPCResponse response = respond(server, new JSONRPCRequest("initialize", 1, params)).block(); + + JSONRPCError error = response.error(); + assertThat(error.code()).isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION); + assertThat(error.message()).contains("2025-11-25", "2026-07-28, 2026-12-01"); + assertThat(error.data()) + .isEqualTo(new McpSchema.UnsupportedProtocolVersionData(List.of("2026-07-28", "2026-12-01"), "2025-11-25")); + } + + @Test + void legacyInitializeWithoutProtocolVersionNamesSupportedVersions() { + McpServer server = baseBuilder().build(); + + JSONRPCResponse response = respond(server, new JSONRPCRequest("initialize", 1, Map.of())).block(); + + JSONRPCError error = response.error(); + assertThat(error.code()).isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION); + assertThat(error.message()).isEqualTo( + "Unsupported protocol version: this server does not support the initialize handshake; supported protocol versions: " + + McpSchema.LATEST_PROTOCOL_VERSION); + assertThat(error.data()) + .isEqualTo(new McpSchema.UnsupportedProtocolVersionData(List.of(McpSchema.LATEST_PROTOCOL_VERSION), "")); + } + + @Test + void initializeWithBlankMetaVersionIsTreatedAsLegacy() { + McpServer server = baseBuilder().build(); + + JSONRPCResponse response = respond(server, request("initialize", meta(MetaKeys.PROTOCOL_VERSION, ""))).block(); + + JSONRPCError error = response.error(); + assertThat(error.code()).isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION); + assertThat(error.message()).contains(McpSchema.LATEST_PROTOCOL_VERSION); + assertThat(error.data()) + .isEqualTo(new McpSchema.UnsupportedProtocolVersionData(List.of(McpSchema.LATEST_PROTOCOL_VERSION), "")); + } + + @Test + void initializeWithModernMetaIsMethodNotFound() { + McpServer server = baseBuilder().build(); + + StepVerifier.create(invoke(server, request("initialize", meta()))) + .assertNext(invocation -> assertError(invocation, ErrorCodes.METHOD_NOT_FOUND)) + .verifyComplete(); + } + @Test void malformedClientCapabilitiesIsInvalidParams() { McpServer server = baseBuilder().feature(echoFeature("tools/call")).build(); 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 56c19de04..fe34afc92 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 @@ -402,6 +402,64 @@ void getIsRejected() throws Exception { assertThat(response.statusCode()).isEqualTo(405); } + @Test + void legacyInitializeIsAnsweredWithSupportedVersions() throws Exception { + // What a 2025-era Streamable HTTP client sends: no Mcp-* headers, no _meta. + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25",\ + "capabilities":{},"clientInfo":{"name":"legacy-client","version":"1.0.0"}}}""")) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION); + assertThatJson(response.body()).inPath("$.error.message") + .asString() + .contains(McpSchema.LATEST_PROTOCOL_VERSION); + assertThatJson(response.body()).inPath("$.error.data").isEqualTo(""" + {"supported": ["%s"], "requested": "2025-11-25"}""".formatted(McpSchema.LATEST_PROTOCOL_VERSION)); + } + + @Test + void initializeWithBlankMetaVersionIsAnsweredWithSupportedVersions() throws Exception { + // A blank _meta version names no revision: no header is required, and the server + // rejects it like a legacy initialize. + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":1,"method":"initialize",\ + "params":{"_meta":{"io.modelcontextprotocol/protocolVersion":""}}}""")) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION); + assertThatJson(response.body()).inPath("$.error.data").isEqualTo(""" + {"supported": ["%s"], "requested": ""}""".formatted(McpSchema.LATEST_PROTOCOL_VERSION)); + } + + @Test + void initializeWithModernMetaStillRequiresHeaders() throws Exception { + Map body = Map.of("jsonrpc", "2.0", "id", 1, "method", "initialize", "params", + Map.of("_meta", meta())); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(JSON_MAPPER.writeValueAsString(body))) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(400); + assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH); + } + @Test void headerMismatchIsRejected() 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 f7303ffcf..4e8a23862 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 @@ -279,6 +279,32 @@ void cancelledNotificationStopsOutputForThatRequest() throws Exception { await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(sawCancel.get()).isTrue()); } + @Test + void legacyInitializeIsAnsweredWithSupportedVersions() throws Exception { + start(McpServer.builder().serverInfo(SERVER_INFO).jsonMapper(this.jsonMapper).build()); + + sendRaw(""" + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25",\ + "capabilities":{},"clientInfo":{"name":"legacy-client","version":"1.0.0"}}}"""); + + String response = readLineWithTimeout(); + assertThatJson(response).isEqualTo(json(""" + { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": %d, + "message": "${json-unit.any-string}", + "data": { + "supported": ["2026-07-28"], + "requested": "2025-11-25" + } + } + } + """.formatted(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION))); + assertThatJson(response).inPath("$.error.message").asString().contains("2026-07-28"); + } + @Test void invalidJsonProducesParseError() throws Exception { McpRequestManager manager = managerOf((transportContext, request) -> Mono