Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ private Mono<McpTransportResponse> dispatch(McpTransportContext transportContext
return invalidParams(id, "params is malformed");
}
Map<String, Object> 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");
}
Expand Down Expand Up @@ -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.
* <p>
* 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 <a href=
* "https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation">Protocol
* Version Negotiation</a>
*/
private Mono<McpTransportResponse> rejectLegacyInitialize(Object id, Map<String, Object> 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<String, Object> meta) {
return meta != null && meta.get(MetaKeys.PROTOCOL_VERSION) instanceof String protocolVersion
&& !protocolVersion.isBlank();
}

private static Mono<McpTransportResponse> error(Object id, JSONRPCError error) {
return Mono.just(McpTransportResponse.error(JSONRPCResponse.error(id, error)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> methodHeaders = Collections.list(request.getHeaders(Headers.MCP_METHOD));
if (methodHeaders.isEmpty()) {
return "Missing required header: Mcp-Method";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,58 @@ void unsupportedVersionWinsOverMissingClientCapabilities() {
.verifyComplete();
}

@Test
void legacyInitializeNamesSupportedVersions() {
McpServer server = baseBuilder().supportedVersions(List.of("2026-07-28", "2026-12-01")).build();
Map<String, Object> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> 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<String, Object> 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<String> 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<String, Object> params = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading