Skip to content

Commit 6b3dcf8

Browse files
committed
Reject initialize requests with UnsupportedProtocolVersion
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent c531bb8 commit 6b3dcf8

6 files changed

Lines changed: 160 additions & 0 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/McpSchema.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ private McpSchema() {
5353

5454
public static final String METHOD_SERVER_DISCOVER = "server/discover";
5555

56+
/**
57+
* The handshake that opens a connection in the initialization-based (legacy) protocol
58+
* versions. It is not part of this revision: servers only recognize it so they can
59+
* reject it with an error naming the versions they support.
60+
*/
61+
@Deprecated
62+
public static final String METHOD_INITIALIZE = "initialize";
63+
5664
public static final String METHOD_TOOLS_LIST = "tools/list";
5765

5866
public static final String METHOD_TOOLS_CALL = "tools/call";

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ private Mono<McpTransportResponse> handle(McpTransportContext transportContext,
125125
private Mono<McpTransportResponse> dispatch(McpTransportContext transportContext, JSONRPCRequest request,
126126
boolean blocking) {
127127
Object id = request.id();
128+
if (McpSchema.METHOD_INITIALIZE.equals(request.method()) && !hasModernVersionClaim(request.params())) {
129+
// Legacy client: tries to initialize and doesn't pass version header. We
130+
// reject early to return the correct error and not a header-validation error.
131+
// If there is a modern version header, it's an invalid INITIALIZE request
132+
// from a modern client: let it fall through, it will be rejected with
133+
// METHOD_NOT_FOUND
134+
return rejectLegacyInitialize(id, request.params());
135+
}
128136
if (request.params() == null) {
129137
return invalidParams(id, "params is required");
130138
}
@@ -308,6 +316,40 @@ private static JSONRPCResponse bug(McpRequestContext ctx, String problem) {
308316
return JSONRPCResponse.error(ctx.requestId(), new JSONRPCError(ErrorCodes.INTERNAL_ERROR, "Internal error"));
309317
}
310318

319+
/**
320+
* Answers the {@code initialize} handshake of a client on an initialization-based
321+
* (legacy) protocol version. The server broadcasts to name its supported version(s)
322+
* in the error response.
323+
* <p>
324+
* This runs before any {@code params}/{@code _meta} validation: a legacy
325+
* {@code initialize} carries neither, and would otherwise be answered with an error
326+
* about the missing {@code _meta} instead.
327+
*
328+
* @see <a href=
329+
* "https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation">Protocol
330+
* Version Negotiation</a>
331+
*/
332+
private Mono<McpTransportResponse> rejectLegacyInitialize(Object id, Object params) {
333+
// The legacy handshake names the client's version in params.protocolVersion. When
334+
// it is missing, "requested" is left out of the data rather than made up.
335+
String requested = params instanceof Map<?, ?> paramsMap
336+
&& paramsMap.get("protocolVersion") instanceof String version ? version : null;
337+
String message = "Unsupported protocol version" + (requested != null ? " " + requested : "[undefined]")
338+
+ ": this server does not support the initialize handshake; supported protocol versions: "
339+
+ String.join(", ", this.supportedVersions);
340+
return error(id, new JSONRPCError(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION, message,
341+
new UnsupportedProtocolVersionData(this.supportedVersions, requested)));
342+
}
343+
344+
/**
345+
* Whether {@code params._meta} names a protocol version, i.e. the request claims to
346+
* follow a per-request-metadata revision rather than the legacy handshake.
347+
*/
348+
private static boolean hasModernVersionClaim(Object params) {
349+
return params instanceof Map<?, ?> paramsMap && paramsMap.get("_meta") instanceof Map<?, ?> meta
350+
&& meta.get(MetaKeys.PROTOCOL_VERSION) instanceof String;
351+
}
352+
311353
private static Mono<McpTransportResponse> error(Object id, JSONRPCError error) {
312354
return Mono.just(McpTransportResponse.error(JSONRPCResponse.error(id, error)));
313355
}

‎mcp-core/src/main/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransport.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,15 @@ private void writeJson(HttpServletResponse response, int status, JSONRPCResponse
413413
* consistent with the body
414414
*/
415415
private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRpcRequest) {
416+
if (McpSchema.METHOD_INITIALIZE.equals(jsonRpcRequest.method())
417+
&& protocolVersionFromMeta(jsonRpcRequest.params()) == null) {
418+
// A client on an initialization-based (legacy) version opens with initialize
419+
// and sends none of these headers. Its request is left to the request
420+
// manager, which knows the supported versions and names them in its
421+
// rejection: answering "Missing required header" would leave that client with
422+
// nothing actionable. Fall through to the server validation.
423+
return null;
424+
}
416425
List<String> methodHeaders = Collections.list(request.getHeaders(Headers.MCP_METHOD));
417426
if (methodHeaders.isEmpty()) {
418427
return "Missing required header: Mcp-Method";

‎mcp-test/src/test/java/io/modelcontextprotocol/modern/server/McpServerTests.java‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,43 @@ void unsupportedVersionWinsOverMissingClientCapabilities() {
108108
.verifyComplete();
109109
}
110110

111+
@Test
112+
void legacyInitializeNamesSupportedVersions() {
113+
McpServer server = baseBuilder().supportedVersions(List.of("2026-07-28", "2026-12-01")).build();
114+
Map<String, Object> params = Map.of("protocolVersion", "2025-11-25", "capabilities", Map.of(), "clientInfo",
115+
Map.of("name", "legacy-client", "version", "1.0.0"));
116+
117+
JSONRPCResponse response = respond(server, new JSONRPCRequest("initialize", 1, params)).block();
118+
119+
JSONRPCError error = response.error();
120+
assertThat(error.code()).isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION);
121+
assertThat(error.message()).contains("2025-11-25", "2026-07-28, 2026-12-01");
122+
assertThat(error.data())
123+
.isEqualTo(new McpSchema.UnsupportedProtocolVersionData(List.of("2026-07-28", "2026-12-01"), "2025-11-25"));
124+
}
125+
126+
@Test
127+
void legacyInitializeWithoutParamsNamesSupportedVersions() {
128+
McpServer server = baseBuilder().build();
129+
130+
JSONRPCResponse response = respond(server, new JSONRPCRequest("initialize", 1, null)).block();
131+
132+
JSONRPCError error = response.error();
133+
assertThat(error.code()).isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION);
134+
assertThat(error.message()).contains(McpSchema.LATEST_PROTOCOL_VERSION);
135+
assertThat(error.data())
136+
.isEqualTo(new McpSchema.UnsupportedProtocolVersionData(List.of(McpSchema.LATEST_PROTOCOL_VERSION), null));
137+
}
138+
139+
@Test
140+
void initializeWithModernMetaIsMethodNotFound() {
141+
McpServer server = baseBuilder().build();
142+
143+
StepVerifier.create(invoke(server, request("initialize", meta())))
144+
.assertNext(invocation -> assertError(invocation, ErrorCodes.METHOD_NOT_FOUND))
145+
.verifyComplete();
146+
}
147+
111148
@Test
112149
void malformedClientCapabilitiesIsInvalidParams() {
113150
McpServer server = baseBuilder().feature(echoFeature("tools/call")).build();

‎mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/HttpServletMcpTransportIntegrationTests.java‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,44 @@ void getIsRejected() throws Exception {
402402
assertThat(response.statusCode()).isEqualTo(405);
403403
}
404404

405+
@Test
406+
void legacyInitializeIsAnsweredWithSupportedVersions() throws Exception {
407+
// What a 2025-era Streamable HTTP client sends: no Mcp-* headers, no _meta.
408+
HttpRequest request = HttpRequest.newBuilder()
409+
.uri(URI.create("http://localhost:" + PORT + ENDPOINT))
410+
.header("Content-Type", "application/json")
411+
.header("Accept", "application/json, text/event-stream")
412+
.POST(HttpRequest.BodyPublishers.ofString("""
413+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25",\
414+
"capabilities":{},"clientInfo":{"name":"legacy-client","version":"1.0.0"}}}"""))
415+
.build();
416+
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
417+
418+
assertThat(response.statusCode()).isEqualTo(400);
419+
assertThatJson(response.body()).inPath("$.error.code").isEqualTo(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION);
420+
assertThatJson(response.body()).inPath("$.error.message")
421+
.asString()
422+
.contains(McpSchema.LATEST_PROTOCOL_VERSION);
423+
assertThatJson(response.body()).inPath("$.error.data").isEqualTo("""
424+
{"supported": ["%s"], "requested": "2025-11-25"}""".formatted(McpSchema.LATEST_PROTOCOL_VERSION));
425+
}
426+
427+
@Test
428+
void initializeWithModernMetaStillRequiresHeaders() throws Exception {
429+
Map<String, Object> body = Map.of("jsonrpc", "2.0", "id", 1, "method", "initialize", "params",
430+
Map.of("_meta", meta()));
431+
HttpRequest request = HttpRequest.newBuilder()
432+
.uri(URI.create("http://localhost:" + PORT + ENDPOINT))
433+
.header("Content-Type", "application/json")
434+
.header("Accept", "application/json, text/event-stream")
435+
.POST(HttpRequest.BodyPublishers.ofString(JSON_MAPPER.writeValueAsString(body)))
436+
.build();
437+
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
438+
439+
assertThat(response.statusCode()).isEqualTo(400);
440+
assertThat(errorCode(response)).isEqualTo(ErrorCodes.HEADER_MISMATCH);
441+
}
442+
405443
@Test
406444
void headerMismatchIsRejected() throws Exception {
407445
Map<String, Object> params = new HashMap<>();

‎mcp-test/src/test/java/io/modelcontextprotocol/modern/server/transport/StdioMcpTransportTests.java‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,32 @@ void cancelledNotificationStopsOutputForThatRequest() throws Exception {
279279
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(sawCancel.get()).isTrue());
280280
}
281281

282+
@Test
283+
void legacyInitializeIsAnsweredWithSupportedVersions() throws Exception {
284+
start(McpServer.builder().serverInfo(SERVER_INFO).jsonMapper(this.jsonMapper).build());
285+
286+
sendRaw("""
287+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25",\
288+
"capabilities":{},"clientInfo":{"name":"legacy-client","version":"1.0.0"}}}""");
289+
290+
String response = readLineWithTimeout();
291+
assertThatJson(response).isEqualTo(json("""
292+
{
293+
"jsonrpc": "2.0",
294+
"id": 1,
295+
"error": {
296+
"code": %d,
297+
"message": "${json-unit.any-string}",
298+
"data": {
299+
"supported": ["2026-07-28"],
300+
"requested": "2025-11-25"
301+
}
302+
}
303+
}
304+
""".formatted(ErrorCodes.UNSUPPORTED_PROTOCOL_VERSION)));
305+
assertThatJson(response).inPath("$.error.message").asString().contains("2026-07-28");
306+
}
307+
282308
@Test
283309
void invalidJsonProducesParseError() throws Exception {
284310
McpRequestManager manager = managerOf((transportContext, request) -> Mono

0 commit comments

Comments
 (0)