Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ McpTransport transport = new StdioClientTransport(params, McpJsonDefaults.getMap
- Configurable connect timeout
- Custom HTTP request customization
- Multiple protocol version negotiation
- SEP-2243 header mirroring: every POST carries an `Mcp-Method` header, and requests
targeting a tool, prompt, or resource also carry `Mcp-Name` (the name or URI), so
servers can validate the headers against the body without parsing it.

=== "Streamable WebClient (external)"

Expand Down
4 changes: 4 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ Key features:
- Configurable keep-alive intervals
- Security validation support
- Graceful shutdown support
- SEP-2243 validation: the servlet transport rejects requests whose present
`Mcp-Method` / `Mcp-Name` headers do not mirror the request body, and rejects
unsupported `MCP-Protocol-Version` values. Missing headers are tolerated so legacy
clients keep working.

=== "Streamable HTTP WebFlux (external)"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,11 +543,32 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
var builder = requestBuilder.uri(uri)
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION,
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
this.latestSupportedProtocolVersion))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
.header(HttpHeaders.CACHE_CONTROL, "no-cache");
// Per the Streamable HTTP transport spec, the MCP-Protocol-Version header
// is required on all requests after initialization completes. The
// initialize request itself carries no negotiated version yet -- the
// client's supported versions are conveyed in the request body for
// server-side negotiation -- so the header must not be sent.
if (!(sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcMessage
&& McpSchema.METHOD_INITIALIZE.equals(jsonrpcMessage.method()))) {
builder = builder.header(HttpHeaders.PROTOCOL_VERSION, ctx
.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, this.latestSupportedProtocolVersion));
}
// Per SEP-2243, mirror the JSON-RPC method and, where applicable, the
// target name/URI in dedicated headers so the server can validate
// them without parsing the body.
if (sentMessage instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcRequest.method());
String name = extractNameFromParams(jsonrpcRequest.method(), jsonrpcRequest.params());
if (name != null) {
builder = builder.header(HttpHeaders.MCP_NAME, name);
}
}
else if (sentMessage instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcNotification.method());
}

builder = builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono
.from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext));
Expand Down Expand Up @@ -740,6 +761,45 @@ public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return this.jsonMapper.convertValue(data, typeRef);
}

/**
* Extracts the name or URI of the tool, prompt, or resource referenced by a request,
* used to populate the SEP-2243 {@code Mcp-Name} header.
* @param method the JSON-RPC method of the request
* @param params the request parameters
* @return the target name or URI when the method references one, otherwise
* {@code null}
*/
private String extractNameFromParams(String method, Object params) {
if (params == null) {
return null;
}

try {
return switch (method) {
case McpSchema.METHOD_TOOLS_CALL ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.CallToolRequest>() {
}).name();
case McpSchema.METHOD_PROMPT_GET ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.GetPromptRequest>() {
}).name();
case McpSchema.METHOD_RESOURCES_READ ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.ReadResourceRequest>() {
}).uri();
case McpSchema.METHOD_RESOURCES_SUBSCRIBE ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.SubscribeRequest>() {
}).uri();
case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.UnsubscribeRequest>() {
}).uri();
default -> null;
};
}
catch (Exception e) {
logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage());
return null;
}
}

/**
* Builder for {@link HttpClientStreamableHttpTransport}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@

import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;

import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
Expand Down Expand Up @@ -186,6 +188,22 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)

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

// The MCP-Protocol-Version header can only be strictly validated once a
// version has been negotiated; during 'initialize' the client advertises its
// versions in the request body and any header value is resolved by regular
// version negotiation instead of being rejected.
boolean initializationRequest = message instanceof McpSchema.JSONRPCRequest initRequestCheck
&& McpSchema.METHOD_INITIALIZE.equals(initRequestCheck.method());
if (!initializationRequest && !validateProtocolVersion(request, response)) {
return;
}

// Per SEP-2243, reject header/body mismatches (missing headers are tolerated
// so legacy clients keep working).
if (!validateMcpHeaders(request, response, message)) {
return;
}

if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
Expand Down Expand Up @@ -266,6 +284,120 @@ private void responseError(HttpServletResponse response, int httpCode, McpError
writer.flush();
}

/**
* Validates the {@code MCP-Protocol-Version} header against the protocol versions
* supported by this transport. A missing header is allowed and falls back to the
* negotiated protocol version, while a header carrying an unsupported version is
* rejected with a 400 Bad Request. Initialize requests are exempt: no version has
* been negotiated yet, so any header value carried on them is resolved through
* regular body-based version negotiation.
* @param request the HTTP servlet request
* @param response the HTTP servlet response
* @return true if the header is missing or contains a supported version, false if a
* 400 error response has been written
* @throws IOException if an I/O error occurs
*/
private boolean validateProtocolVersion(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String protocolVersion = request.getHeader(HttpHeaders.PROTOCOL_VERSION);
if (protocolVersion == null || this.protocolVersions().contains(protocolVersion)) {
return true;
}
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
.message("Unsupported protocol version (supported versions: "
+ String.join(", ", this.protocolVersions()) + ")")
.build());
return false;
}

/**
* Validates the SEP-2243 {@code Mcp-Method} and {@code Mcp-Name} request headers
* against the deserialized message body. Missing headers are permitted for backwards
* compatibility with legacy clients, but any header that is supplied must match the
* corresponding payload attribute. Mismatches are rejected with a 400 Bad Request.
* @param request the incoming servlet request
* @param response the servlet response used to write an error payload if validation
* fails
* @param message the parsed JSON-RPC message
* @return {@code true} if validation passed, {@code false} if a 400 response was
* written
* @throws IOException if writing the error response fails
*/
private boolean validateMcpHeaders(HttpServletRequest request, HttpServletResponse response,
McpSchema.JSONRPCMessage message) throws IOException {
String method = message instanceof McpSchema.JSONRPCRequest req ? req.method()
: message instanceof McpSchema.JSONRPCNotification notif ? notif.method() : null;

if (method == null) {
return true;
}

String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD);
if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'")
.build());
return false;
}

Object params = message instanceof McpSchema.JSONRPCRequest req ? req.params()
: message instanceof McpSchema.JSONRPCNotification notif ? notif.params() : null;
String name = extractNameFromParams(method, params);
if (name != null) {
String nameHeader = request.getHeader(HttpHeaders.MCP_NAME);
if (nameHeader != null && !nameHeader.isBlank() && !name.equals(nameHeader)) {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'")
.build());
return false;
}
}

return true;
}

/**
* Extracts the name or URI of the tool, prompt, or resource referenced by a request,
* as used to validate the SEP-2243 {@code Mcp-Name} header.
* @param method the JSON-RPC method of the request
* @param params the request parameters
* @return the target name or URI when the method references one, otherwise
* {@code null}
*/
private String extractNameFromParams(String method, Object params) {
if (params == null) {
return null;
}

try {
return switch (method) {
case McpSchema.METHOD_TOOLS_CALL ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.CallToolRequest>() {
}).name();
case McpSchema.METHOD_PROMPT_GET ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.GetPromptRequest>() {
}).name();
case McpSchema.METHOD_RESOURCES_READ ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.ReadResourceRequest>() {
}).uri();
case McpSchema.METHOD_RESOURCES_SUBSCRIBE ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.SubscribeRequest>() {
}).uri();
case McpSchema.METHOD_RESOURCES_UNSUBSCRIBE ->
this.jsonMapper.convertValue(params, new TypeRef<McpSchema.UnsubscribeRequest>() {
}).uri();
default -> null;
};
}
catch (Exception e) {
logger.debug("Failed to extract name from params for method {}: {}", method, e.getMessage());
return null;
}
}

/**
* Cleans up resources when the servlet is being destroyed.
* <p>
Expand Down
Loading
Loading