Skip to content

Commit 67e89e7

Browse files
committed
Add opt-in requireMcpHeaders and share SEP-2243 validation across servlet transports
Move the duplicated MCP-Protocol-Version / Mcp-Method / Mcp-Name validation out of both servlet transports into a package-private Sep2243RequestValidator they share. An unsupported MCP-Protocol-Version now returns INVALID_REQUEST instead of the previously incorrect METHOD_NOT_FOUND. requireMcpHeaders defaults to false, so clients that do not send the SEP-2243 headers are unaffected.
1 parent ac69692 commit 67e89e7

5 files changed

Lines changed: 369 additions & 261 deletions

File tree

docs/server.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ Key features:
170170
- SEP-2243 validation: the servlet transport rejects requests whose present
171171
`Mcp-Method` / `Mcp-Name` headers do not mirror the request body, and rejects
172172
unsupported `MCP-Protocol-Version` values. Missing headers are tolerated so legacy
173-
clients keep working.
173+
clients keep working. Call `.requireMcpHeaders(true)` on the builder to also
174+
reject requests that omit them.
174175

175176
=== "Streamable HTTP WebFlux (external)"
176177

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

Lines changed: 39 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@
1313

1414
import io.modelcontextprotocol.json.McpJsonDefaults;
1515
import io.modelcontextprotocol.json.McpJsonMapper;
16-
import io.modelcontextprotocol.json.TypeRef;
1716

1817
import io.modelcontextprotocol.common.McpTransportContext;
1918
import io.modelcontextprotocol.server.McpStatelessServerHandler;
2019
import io.modelcontextprotocol.server.McpTransportContextExtractor;
21-
import io.modelcontextprotocol.spec.HttpHeaders;
2220
import io.modelcontextprotocol.spec.McpError;
2321
import io.modelcontextprotocol.spec.McpSchema;
2422
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
@@ -71,6 +69,12 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
7169
*/
7270
private final ServerHttpHeaderValidator httpHeaderValidator;
7371

72+
/**
73+
* Validator for the SEP-2243 {@code MCP-Protocol-Version}, {@code Mcp-Method}, and
74+
* {@code Mcp-Name} header checks that need the parsed JSON-RPC body.
75+
*/
76+
private final Sep2243RequestValidator sep2243Validator;
77+
7478
/**
7579
* Maximum size, in bytes, of a single request body accepted by this transport.
7680
*/
@@ -86,11 +90,13 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
8690
* @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
8791
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
8892
* positive.
93+
* @param requireMcpHeaders Whether a POST lacking the SEP-2243 {@code Mcp-Method} /
94+
* {@code Mcp-Name} headers is rejected instead of tolerated.
8995
* @throws IllegalArgumentException if any parameter is null
9096
*/
9197
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
9298
McpTransportContextExtractor<HttpServletRequest> contextExtractor,
93-
ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
99+
ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize, boolean requireMcpHeaders) {
94100
Assert.notNull(jsonMapper, "jsonMapper must not be null");
95101
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
96102
Assert.notNull(contextExtractor, "contextExtractor must not be null");
@@ -102,6 +108,7 @@ private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcp
102108
this.contextExtractor = contextExtractor;
103109
this.httpHeaderValidator = httpHeaderValidator;
104110
this.requestMaxSize = requestMaxSize;
111+
this.sep2243Validator = new Sep2243RequestValidator(jsonMapper, this::protocolVersions, requireMcpHeaders);
105112
}
106113

107114
@Override
@@ -187,18 +194,20 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
187194
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
188195

189196
// The MCP-Protocol-Version header can only be strictly validated once a
190-
// version has been negotiated; during 'initialize' the client advertises its
191-
// versions in the request body and any header value is resolved by regular
192-
// version negotiation instead of being rejected.
193-
boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck
194-
&& McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method());
195-
if (!initializationRequest && !validateProtocolVersion(request, response)) {
197+
// version has been negotiated; 'initialize' requests are exempt.
198+
HttpServletHeaderAccessor headerAccessor = new HttpServletHeaderAccessor(request);
199+
McpError protocolVersionError = this.sep2243Validator.validateProtocolVersion(headerAccessor,
200+
Sep2243RequestValidator.isInitializeRequest(message));
201+
if (protocolVersionError != null) {
202+
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, protocolVersionError);
196203
return;
197204
}
198205

199206
// Per SEP-2243, reject header/body mismatches (missing headers are tolerated
200-
// so legacy clients keep working).
201-
if (!validateMcpHeaders(request, response, message)) {
207+
// by default so legacy clients keep working).
208+
McpError mirroringError = this.sep2243Validator.validateMirroringHeaders(headerAccessor, message);
209+
if (mirroringError != null) {
210+
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, mirroringError);
202211
return;
203212
}
204213

@@ -282,124 +291,6 @@ private void responseError(HttpServletResponse response, int httpCode, McpError
282291
writer.flush();
283292
}
284293

285-
/**
286-
* Validates the {@code MCP-Protocol-Version} header against the protocol versions
287-
* supported by this transport. A missing header is allowed and falls back to the
288-
* negotiated protocol version, while a header carrying an unsupported version is
289-
* rejected with a 400 Bad Request. Initialize requests are exempt: no version has
290-
* been negotiated yet, so any header value carried on them is resolved through
291-
* regular body-based version negotiation.
292-
* @param request the HTTP servlet request
293-
* @param response the HTTP servlet response
294-
* @return true if the header is missing or contains a supported version, false if a
295-
* 400 error response has been written
296-
* @throws IOException if an I/O error occurs
297-
*/
298-
private boolean validateProtocolVersion(HttpServletRequest request, HttpServletResponse response)
299-
throws IOException {
300-
String protocolVersion = request.getHeader(HttpHeaders.PROTOCOL_VERSION);
301-
if (protocolVersion == null || this.protocolVersions().contains(protocolVersion)) {
302-
return true;
303-
}
304-
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
305-
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
306-
.message("Unsupported protocol version (supported versions: "
307-
+ String.join(", ", this.protocolVersions()) + ")")
308-
.build());
309-
return false;
310-
}
311-
312-
/**
313-
* Validates the SEP-2243 {@code Mcp-Method} and {@code Mcp-Name} request headers
314-
* against the deserialized message body. Missing headers are permitted for backwards
315-
* compatibility with legacy clients, but any header that is supplied must match the
316-
* corresponding payload attribute. Mismatches are rejected with a 400 Bad Request.
317-
* @param request the incoming servlet request
318-
* @param response the servlet response used to write an error payload if validation
319-
* fails
320-
* @param message the parsed JSON-RPC message
321-
* @return {@code true} if validation passed, {@code false} if a 400 response was
322-
* written
323-
* @throws IOException if writing the error response fails
324-
*/
325-
private boolean validateMcpHeaders(HttpServletRequest request, HttpServletResponse response,
326-
McpSchema.JSONRPCMessage message) throws IOException {
327-
String method = message instanceof McpSchema.JSONRPCRequest req ? req.method()
328-
: message instanceof McpSchema.JSONRPCNotification notif ? notif.method() : null;
329-
330-
if (method == null) {
331-
return true;
332-
}
333-
334-
String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD);
335-
if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) {
336-
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
337-
McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH)
338-
.message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'")
339-
.build());
340-
return false;
341-
}
342-
343-
Object params = message instanceof McpSchema.JSONRPCRequest req ? req.params()
344-
: message instanceof McpSchema.JSONRPCNotification notif ? notif.params() : null;
345-
String name = extractNameFromParams(method, params);
346-
if (name != null) {
347-
String nameHeader = request.getHeader(HttpHeaders.MCP_NAME);
348-
if (nameHeader != null && !nameHeader.isBlank()) {
349-
String decodedName = HttpHeaders.decodeHeaderValue(nameHeader);
350-
if (!name.equals(decodedName)) {
351-
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
352-
McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH)
353-
.message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader
354-
+ "'")
355-
.build());
356-
return false;
357-
}
358-
}
359-
}
360-
361-
return true;
362-
}
363-
364-
/**
365-
* Extracts the name or URI of the tool, prompt, or resource referenced by a request,
366-
* as used to validate the SEP-2243 {@code Mcp-Name} header.
367-
* @param method the JSON-RPC method of the request
368-
* @param params the request parameters
369-
* @return the target name or URI when the method references one, otherwise
370-
* {@code null}
371-
*/
372-
private String extractNameFromParams(String method, Object params) {
373-
if (params == null) {
374-
return null;
375-
}
376-
377-
try {
378-
return switch (method) {
379-
case McpSchema.METHOD_TOOLS_CALL ->
380-
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.CallToolRequest>() {
381-
}).name();
382-
case McpSchema.METHOD_PROMPT_GET ->
383-
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.GetPromptRequest>() {
384-
}).name();
385-
case McpSchema.METHOD_RESOURCES_READ ->
386-
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.ReadResourceRequest>() {
387-
}).uri();
388-
case McpSchema.METHOD_RESOURCES_SUBSCRIBE ->
389-
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.SubscribeRequest>() {
390-
}).uri();
391-
case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE ->
392-
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.UnsubscribeRequest>() {
393-
}).uri();
394-
default -> null;
395-
};
396-
}
397-
catch (Exception e) {
398-
logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage());
399-
return null;
400-
}
401-
}
402-
403294
/**
404295
* Cleans up resources when the servlet is being destroyed.
405296
* <p>
@@ -438,6 +329,8 @@ public static class Builder {
438329

439330
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
440331

332+
private boolean requireMcpHeaders = false;
333+
441334
private Builder() {
442335
// used by a static method
443336
}
@@ -523,6 +416,22 @@ public Builder maxRequestSize(int requestMaxSize) {
523416
return this;
524417
}
525418

419+
/**
420+
* Opt-in strict mode for SEP-2243. When enabled, a POST whose JSON-RPC request or
421+
* notification carries no {@code Mcp-Method} header, or targets a tool, prompt or
422+
* resource without an {@code Mcp-Name} header, is rejected with HTTP 400 and
423+
* error code {@code HEADER_MISMATCH} (-32020). Disabled by default so that
424+
* clients that do not send these headers keep working. A present header that
425+
* mismatches the body is always rejected regardless of this setting.
426+
* @param requireMcpHeaders whether to require the SEP-2243 {@code Mcp-Method} /
427+
* {@code Mcp-Name} headers
428+
* @return this builder instance
429+
*/
430+
public Builder requireMcpHeaders(boolean requireMcpHeaders) {
431+
this.requireMcpHeaders = requireMcpHeaders;
432+
return this;
433+
}
434+
526435
/**
527436
* Builds a new instance of {@link HttpServletStatelessServerTransport} with the
528437
* configured settings.
@@ -533,7 +442,7 @@ public HttpServletStatelessServerTransport build() {
533442
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
534443
return new HttpServletStatelessServerTransport(
535444
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
536-
httpHeaderValidator, requestMaxSize);
445+
httpHeaderValidator, requestMaxSize, requireMcpHeaders);
537446
}
538447

539448
}

0 commit comments

Comments
 (0)