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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ private McpSchema() {

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

/**
* The handshake that opens a connection in the initialization-based (legacy) protocol
* versions. It is not part of this revision: servers only recognize it so they can
* reject it with an error naming the versions they support.
*/
@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 @@ -125,6 +125,14 @@ private Mono<McpTransportResponse> handle(McpTransportContext transportContext,
private Mono<McpTransportResponse> dispatch(McpTransportContext transportContext, JSONRPCRequest request,
boolean blocking) {
Object id = request.id();
if (McpSchema.METHOD_INITIALIZE.equals(request.method()) && !hasModernVersionClaim(request.params())) {
// 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, request.params());
}
if (request.params() == null) {
return invalidParams(id, "params 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, Object params) {
// The legacy handshake names the client's version in params.protocolVersion. When
// it is missing, "requested" is left out of the data rather than made up.
String requested = params instanceof Map<?, ?> paramsMap
&& paramsMap.get("protocolVersion") instanceof String version ? version : null;
String message = "Unsupported protocol version" + (requested != null ? " " + requested : "[undefined]")
+ ": 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(Object params) {
return params instanceof Map<?, ?> paramsMap && paramsMap.get("_meta") instanceof Map<?, ?> meta
&& meta.get(MetaKeys.PROTOCOL_VERSION) instanceof String;
}

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,15 @@ private void writeJson(HttpServletResponse response, int status, JSONRPCResponse
* consistent with the body
*/
private String validateHeaders(HttpServletRequest request, JSONRPCRequest jsonRpcRequest) {
if (McpSchema.METHOD_INITIALIZE.equals(jsonRpcRequest.method())
&& protocolVersionFromMeta(jsonRpcRequest.params()) == null) {
// 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.
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,43 @@ 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 legacyInitializeWithoutParamsNamesSupportedVersions() {
McpServer server = baseBuilder().build();

JSONRPCResponse response = respond(server, new JSONRPCRequest("initialize", 1, null)).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), null));
}

@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,44 @@ 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 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