Skip to content

Commit ab3acb6

Browse files
committed
fix(transport): exempt initialize requests from MCP-Protocol-Version checks
Per the Streamable HTTP spec the MCP-Protocol-Version header is required only after initialization completes; version selection for initialize happens through body-level negotiation, not header validation. * Client: stop sending MCP-Protocol-Version on initialize requests * Servlet servers: skip strict header validation for initialize so clients advertising an unsupported version negotiate instead of getting 400 * Tests: pin client omission and server tolerance for initialize; make version-negotiation test contextExtractor null-safe for absent headers
1 parent a391e3f commit ab3acb6

6 files changed

Lines changed: 112 additions & 16 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,17 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
543543
var builder = requestBuilder.uri(uri)
544544
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
545545
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
546-
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
547-
.header(HttpHeaders.PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
548-
this.latestSupportedProtocolVersion));
546+
.header(HttpHeaders.CACHE_CONTROL, "no-cache");
547+
// Per the Streamable HTTP transport spec, the MCP-Protocol-Version header
548+
// is required on all requests after initialization completes. The
549+
// initialize request itself carries no negotiated version yet -- the
550+
// client's supported versions are conveyed in the request body for
551+
// server-side negotiation -- so the header must not be sent.
552+
if (!(sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcMessage
553+
&& McpSchema.METHOD_INITIALIZE.equals(jsonrpcMessage.method()))) {
554+
builder = builder.header(HttpHeaders.PROTOCOL_VERSION, ctx
555+
.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, this.latestSupportedProtocolVersion));
556+
}
549557
// Per SEP-2243, mirror the JSON-RPC method and, where applicable, the
550558
// target name/URI in dedicated headers so the server can validate
551559
// them without parsing the body.

mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,6 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
163163
return;
164164
}
165165

166-
if (!validateProtocolVersion(request, response)) {
167-
return;
168-
}
169-
170166
try {
171167
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request);
172168
this.securityValidator.validateHeaders(headers);
@@ -192,6 +188,16 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
192188

193189
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
194190

191+
// The MCP-Protocol-Version header can only be strictly validated once a
192+
// version has been negotiated; during 'initialize' the client advertises its
193+
// versions in the request body and any header value is resolved by regular
194+
// version negotiation instead of being rejected.
195+
boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck
196+
&& McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method());
197+
if (!initializationRequest && !validateProtocolVersion(request, response)) {
198+
return;
199+
}
200+
195201
// Per SEP-2243, reject header/body mismatches (missing headers are tolerated
196202
// so legacy clients keep working).
197203
if (!validateMcpHeaders(request, response, message)) {
@@ -282,7 +288,9 @@ private void responseError(HttpServletResponse response, int httpCode, McpError
282288
* Validates the {@code MCP-Protocol-Version} header against the protocol versions
283289
* supported by this transport. A missing header is allowed and falls back to the
284290
* negotiated protocol version, while a header carrying an unsupported version is
285-
* rejected with a 400 Bad Request.
291+
* rejected with a 400 Bad Request. Initialize requests are exempt: no version has
292+
* been negotiated yet, so any header value carried on them is resolved through
293+
* regular body-based version negotiation.
286294
* @param request the HTTP servlet request
287295
* @param response the HTTP servlet response
288296
* @return true if the header is missing or contains a supported version, false if a

mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,6 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
417417
return;
418418
}
419419

420-
if (!validateProtocolVersion(request, response)) {
421-
return;
422-
}
423-
424420
try {
425421
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request);
426422
this.securityValidator.validateHeaders(headers);
@@ -447,6 +443,16 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
447443

448444
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
449445

446+
// The MCP-Protocol-Version header can only be strictly validated once a
447+
// version has been negotiated; during 'initialize' the client advertises its
448+
// versions in the request body and any header value is resolved by the
449+
// regular version negotiation below instead of being rejected.
450+
boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck
451+
&& McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method());
452+
if (!initializationRequest && !validateProtocolVersion(request, response)) {
453+
return;
454+
}
455+
450456
// Per SEP-2243, reject header/body mismatches (missing headers are tolerated
451457
// so
452458
// legacy clients keep working).
@@ -676,7 +682,9 @@ public void responseError(HttpServletResponse response, int httpCode, McpError m
676682
* Validates the {@code MCP-Protocol-Version} header against the protocol versions
677683
* supported by this transport. A missing header is allowed and falls back to the
678684
* negotiated protocol version, while a header carrying an unsupported version is
679-
* rejected with a 400 Bad Request.
685+
* rejected with a 400 Bad Request. Initialize requests are exempt: no version has
686+
* been negotiated yet, so any header value carried on them is resolved through
687+
* regular body-based version negotiation.
680688
* @param request the HTTP servlet request
681689
* @param response the HTTP servlet response
682690
* @return true if the header is missing or contains a supported version, false if a

mcp-test/src/test/java/io/modelcontextprotocol/client/transport/Sep2243ClientRequestHeaderTests.java

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.sun.net.httpserver.HttpServer;
88
import io.modelcontextprotocol.spec.HttpHeaders;
99
import io.modelcontextprotocol.spec.McpSchema;
10+
import io.modelcontextprotocol.spec.ProtocolVersions;
1011
import java.io.IOException;
1112
import java.net.InetSocketAddress;
1213
import java.util.Map;
@@ -93,4 +94,43 @@ void emitsMcpMethodForNotification() throws IOException {
9394
}
9495
}
9596

96-
}
97+
@Test
98+
void omitsMcpProtocolVersionHeaderOnInitializeRequest() throws IOException {
99+
var seenProtocolVersions = new java.util.concurrent.CopyOnWriteArrayList<String>();
100+
var server = HttpServer.create(new InetSocketAddress(0), 0);
101+
102+
try {
103+
server.createContext("/mcp", exchange -> {
104+
seenProtocolVersions.add(exchange.getRequestHeaders().getFirst(HttpHeaders.PROTOCOL_VERSION));
105+
exchange.getRequestBody().readAllBytes();
106+
exchange.sendResponseHeaders(202, -1);
107+
exchange.close();
108+
});
109+
server.start();
110+
111+
var transport = HttpClientStreamableHttpTransport
112+
.builder("http://localhost:" + server.getAddress().getPort())
113+
.endpoint("/mcp")
114+
.supportedProtocolVersions(java.util.List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18"))
115+
.build();
116+
117+
try {
118+
// The initialize request carries the client's latest supported version in
119+
// its body for negotiation; sending an MCP-Protocol-Version header would
120+
// make strict servers reject it before negotiation happens.
121+
var initRequest = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id",
122+
Map.of("protocolVersion", "2263-03-18"));
123+
StepVerifier.create(transport.sendMessage(initRequest)).verifyComplete();
124+
}
125+
finally {
126+
StepVerifier.create(transport.closeGracefully()).verifyComplete();
127+
}
128+
129+
assertThat(seenProtocolVersions).containsNull();
130+
}
131+
finally {
132+
server.stop(0);
133+
}
134+
}
135+
136+
}

mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import java.util.List;
88
import java.util.Map;
9+
import java.util.Objects;
910
import java.util.function.BiFunction;
1011

1112
import io.modelcontextprotocol.client.McpClient;
@@ -37,8 +38,10 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests {
3738

3839
private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider
3940
.builder()
40-
.contextExtractor(
41-
req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version"))))
41+
// The MCP-Protocol-Version header may legitimately be absent on initialize
42+
// requests, so a missing header must not break context extraction.
43+
.contextExtractor(req -> McpTransportContext
44+
.create(Map.of("protocol-version", Objects.requireNonNullElse(req.getHeader("MCP-protocol-version"), ""))))
4245
.build();
4346

4447
private final McpSchema.Tool toolSpec = McpSchema.Tool.builder("test-tool")

mcp-test/src/test/java/io/modelcontextprotocol/server/transport/Sep2243ServerHeaderValidationTests.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,33 @@ void statelessToleratesAbsentHeaders() throws Exception {
138138
"Mcp-Name header");
139139
}
140140

141+
// --- Initialize requests must not be rejected on MCP-Protocol-Version ------------
142+
143+
private static byte[] initializeBody() throws Exception {
144+
var request = new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id",
145+
Map.of("protocolVersion", "2263-03-18"));
146+
return McpJsonMapperUtils.JSON_MAPPER.writeValueAsString(request).getBytes(StandardCharsets.UTF_8);
147+
}
148+
149+
@Test
150+
void streamableToleratesUnsupportedProtocolVersionOnInitialize() throws Exception {
151+
var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build();
152+
153+
// During initialization no protocol version has been negotiated yet, so any
154+
// header value must be resolved through regular version negotiation instead of
155+
// a hard 400.
156+
var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), initializeBody());
157+
158+
assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version");
159+
}
160+
161+
@Test
162+
void statelessToleratesUnsupportedProtocolVersionOnInitialize() throws Exception {
163+
var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build();
164+
165+
var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.PROTOCOL_VERSION, "junk"), initializeBody());
166+
167+
assertThat(resp.getContentAsString()).doesNotContain("Unsupported protocol version");
168+
}
169+
141170
}

0 commit comments

Comments
 (0)