Skip to content

Commit a391e3f

Browse files
committed
feat(transport): add SEP-2243 Mcp-Method / Mcp-Name header mirroring with MCP-Protocol-Version validation
Implement SEP-2243 HTTP header standardization across client and server servlet transports. * Client: Emit 'Mcp-Method' on outbound Streamable HTTP requests and notifications, and 'Mcp-Name' when targeting named tools, prompts, or resources. * Server: Validate 'Mcp-Method' and 'Mcp-Name' headers against deserialized JSON-RPC payloads in HttpServletStreamableServerTransportProvider and HttpServletStatelessServerTransport. Reject mismatches with HTTP 400 while tolerating absent headers for backward compatibility. * Versioning: Validate 'MCP-Protocol-Version' against supported protocol versions on incoming servlet requests. * Tests: Add Sep2243ClientRequestHeaderTests and Sep2243ServerHeaderValidationTests verifying emission, mismatch rejections, and absent-header tolerance.
1 parent b31841e commit a391e3f

8 files changed

Lines changed: 572 additions & 4 deletions

File tree

docs/client.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ McpTransport transport = new StdioClientTransport(params, McpJsonDefaults.getMap
165165
- Configurable connect timeout
166166
- Custom HTTP request customization
167167
- Multiple protocol version negotiation
168+
- SEP-2243 header mirroring: every POST carries an `Mcp-Method` header, and requests
169+
targeting a tool, prompt, or resource also carry `Mcp-Name` (the name or URI), so
170+
servers can validate the headers against the body without parsing it.
168171

169172
=== "Streamable WebClient (external)"
170173

docs/server.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ Key features:
167167
- Configurable keep-alive intervals
168168
- Security validation support
169169
- Graceful shutdown support
170+
- SEP-2243 validation: the servlet transport rejects requests whose present
171+
`Mcp-Method` / `Mcp-Name` headers do not mirror the request body, and rejects
172+
unsupported `MCP-Protocol-Version` values. Missing headers are tolerated so legacy
173+
clients keep working.
170174

171175
=== "Streamable HTTP WebFlux (external)"
172176

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

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,23 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
544544
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
545545
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
546546
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
547-
.header(HttpHeaders.PROTOCOL_VERSION,
548-
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
549-
this.latestSupportedProtocolVersion))
550-
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
547+
.header(HttpHeaders.PROTOCOL_VERSION, ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
548+
this.latestSupportedProtocolVersion));
549+
// Per SEP-2243, mirror the JSON-RPC method and, where applicable, the
550+
// target name/URI in dedicated headers so the server can validate
551+
// them without parsing the body.
552+
if (sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
553+
builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcRequest.method());
554+
String name = extractNameFromParams(jsonrpcRequest.method(), jsonrpcRequest.params());
555+
if (name != null) {
556+
builder = builder.header(HttpHeaders.MCP_NAME, name);
557+
}
558+
}
559+
else if (sentMessage instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
560+
builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcNotification.method());
561+
}
562+
563+
builder = builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
551564
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
552565
return Mono
553566
.from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext));
@@ -740,6 +753,45 @@ public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
740753
return this.jsonMapper.convertValue(data, typeRef);
741754
}
742755

756+
/**
757+
* Extracts the name or URI of the tool, prompt, or resource referenced by a request,
758+
* used to populate the SEP-2243 {@code Mcp-Name} header.
759+
* @param method the JSON-RPC method of the request
760+
* @param params the request parameters
761+
* @return the target name or URI when the method references one, otherwise
762+
* {@code null}
763+
*/
764+
private String extractNameFromParams(String method, Object params) {
765+
if (params == null) {
766+
return null;
767+
}
768+
769+
try {
770+
return switch (method) {
771+
case McpSchema.METHOD_TOOLS_CALL ->
772+
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.CallToolRequest>() {
773+
}).name();
774+
case McpSchema.METHOD_PROMPT_GET ->
775+
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.GetPromptRequest>() {
776+
}).name();
777+
case McpSchema.METHOD_RESOURCES_READ ->
778+
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.ReadResourceRequest>() {
779+
}).uri();
780+
case McpSchema.METHOD_RESOURCES_SUBSCRIBE ->
781+
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.SubscribeRequest>() {
782+
}).uri();
783+
case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE ->
784+
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.UnsubscribeRequest>() {
785+
}).uri();
786+
default -> null;
787+
};
788+
}
789+
catch (Exception e) {
790+
logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage());
791+
return null;
792+
}
793+
}
794+
743795
/**
744796
* Builder for {@link HttpClientStreamableHttpTransport}.
745797
*/

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515
import io.modelcontextprotocol.json.McpJsonDefaults;
1616
import io.modelcontextprotocol.json.McpJsonMapper;
17+
import io.modelcontextprotocol.json.TypeRef;
1718

1819
import io.modelcontextprotocol.common.McpTransportContext;
1920
import io.modelcontextprotocol.server.McpStatelessServerHandler;
2021
import io.modelcontextprotocol.server.McpTransportContextExtractor;
22+
import io.modelcontextprotocol.spec.HttpHeaders;
2123
import io.modelcontextprotocol.spec.McpError;
2224
import io.modelcontextprotocol.spec.McpSchema;
2325
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
@@ -161,6 +163,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
161163
return;
162164
}
163165

166+
if (!validateProtocolVersion(request, response)) {
167+
return;
168+
}
169+
164170
try {
165171
Map<String, List<String>> headers = HttpServletRequestUtils.extractHeaders(request);
166172
this.securityValidator.validateHeaders(headers);
@@ -186,6 +192,12 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
186192

187193
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
188194

195+
// Per SEP-2243, reject header/body mismatches (missing headers are tolerated
196+
// so legacy clients keep working).
197+
if (!validateMcpHeaders(request, response, message)) {
198+
return;
199+
}
200+
189201
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
190202
try {
191203
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
@@ -266,6 +278,118 @@ private void responseError(HttpServletResponse response, int httpCode, McpError
266278
writer.flush();
267279
}
268280

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

0 commit comments

Comments
 (0)