Skip to content

Commit ac69692

Browse files
feat(transport): support Base64 sentinel header encoding for Mcp-Name and return -32020 HeaderMismatch
* Encode non-ASCII and sentinel-matching Mcp-Name values in Base64 sentinel format (=?base64?...?=) in HttpClientStreamableHttpTransport to prevent JDK HttpClient IllegalArgumentException on non-Latin-1 characters. * Decode Base64 sentinel Mcp-Name values on HttpServletStreamableServerTransportProvider and HttpServletStatelessServerTransport prior to body validation. * Add HEADER_MISMATCH (-32020) error code to McpSchema.ErrorCodes per SEP-2243 and return it on header/body mismatches. * Add unit and integration tests for Base64 sentinel encoding/decoding and -32020 mismatch error codes. Co-authored-by: Nikita Kibitkin <nikita.n.kibitkin@gmail.com>
1 parent c34cc78 commit ac69692

7 files changed

Lines changed: 162 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
561561
builder = builder.header(HttpHeaders.MCP_METHOD, jsonrpcRequest.method());
562562
String name = extractNameFromParams(jsonrpcRequest.method(), jsonrpcRequest.params());
563563
if (name != null) {
564-
builder = builder.header(HttpHeaders.MCP_NAME, name);
564+
builder = builder.header(HttpHeaders.MCP_NAME, HttpHeaders.encodeHeaderValue(name));
565565
}
566566
}
567567
else if (sentMessage instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon
334334
String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD);
335335
if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) {
336336
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
337-
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
337+
McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH)
338338
.message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'")
339339
.build());
340340
return false;
@@ -345,12 +345,16 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon
345345
String name = extractNameFromParams(method, params);
346346
if (name != null) {
347347
String nameHeader = request.getHeader(HttpHeaders.MCP_NAME);
348-
if (nameHeader != null && !nameHeader.isBlank() && !name.equals(nameHeader)) {
349-
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
350-
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
351-
.message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'")
352-
.build());
353-
return false;
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+
}
354358
}
355359
}
356360

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon
735735
String methodHeader = request.getHeader(HttpHeaders.MCP_METHOD);
736736
if (methodHeader != null && !methodHeader.isBlank() && !method.equals(methodHeader)) {
737737
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
738-
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
738+
McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH)
739739
.message("Mcp-Method header mismatch: expected '" + method + "' but was '" + methodHeader + "'")
740740
.build());
741741
return false;
@@ -746,12 +746,16 @@ private boolean validateMcpHeaders(HttpServletRequest request, HttpServletRespon
746746
String name = extractNameFromParams(method, params);
747747
if (name != null) {
748748
String nameHeader = request.getHeader(HttpHeaders.MCP_NAME);
749-
if (nameHeader != null && !nameHeader.isBlank() && !name.equals(nameHeader)) {
750-
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
751-
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
752-
.message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader + "'")
753-
.build());
754-
return false;
749+
if (nameHeader != null && !nameHeader.isBlank()) {
750+
String decodedName = HttpHeaders.decodeHeaderValue(nameHeader);
751+
if (!name.equals(decodedName)) {
752+
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
753+
McpError.builder(McpSchema.ErrorCodes.HEADER_MISMATCH)
754+
.message("Mcp-Name header mismatch: expected '" + name + "' but was '" + nameHeader
755+
+ "'")
756+
.build());
757+
return false;
758+
}
755759
}
756760
}
757761

mcp-core/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,78 @@ public interface HttpHeaders {
7373
*/
7474
String CACHE_CONTROL = "Cache-Control";
7575

76+
/**
77+
* Prefix used for Base64 sentinel-encoded header values per SEP-2243.
78+
*/
79+
String BASE64_SENTINEL_PREFIX = "=?base64?";
80+
81+
/**
82+
* Suffix used for Base64 sentinel-encoded header values per SEP-2243.
83+
*/
84+
String BASE64_SENTINEL_SUFFIX = "?=";
85+
86+
/**
87+
* Encodes an HTTP header value per SEP-2243. If the value contains non-ASCII
88+
* characters, control characters, leading/trailing whitespace, or matches the
89+
* sentinel pattern, it is wrapped in Base64 sentinel encoding
90+
* ({@code =?base64?...?=}).
91+
* @param value the raw string value
92+
* @return the header-safe value, encoded if necessary
93+
*/
94+
static String encodeHeaderValue(String value) {
95+
if (value == null || value.isEmpty()) {
96+
return value;
97+
}
98+
if (requiresBase64Encoding(value)) {
99+
return BASE64_SENTINEL_PREFIX
100+
+ java.util.Base64.getEncoder()
101+
.encodeToString(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))
102+
+ BASE64_SENTINEL_SUFFIX;
103+
}
104+
return value;
105+
}
106+
107+
/**
108+
* Decodes an HTTP header value that may be Base64 sentinel-encoded per SEP-2243.
109+
* @param headerValue the raw header value from the HTTP request
110+
* @return the decoded string value, or the original value if not encoded or malformed
111+
*/
112+
static String decodeHeaderValue(String headerValue) {
113+
if (headerValue == null || headerValue.isEmpty()) {
114+
return headerValue;
115+
}
116+
if (headerValue.startsWith(BASE64_SENTINEL_PREFIX) && headerValue.endsWith(BASE64_SENTINEL_SUFFIX)) {
117+
String encoded = headerValue.substring(BASE64_SENTINEL_PREFIX.length(),
118+
headerValue.length() - BASE64_SENTINEL_SUFFIX.length());
119+
try {
120+
byte[] decoded = java.util.Base64.getDecoder().decode(encoded);
121+
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
122+
}
123+
catch (IllegalArgumentException ignored) {
124+
return headerValue;
125+
}
126+
}
127+
return headerValue;
128+
}
129+
130+
private static boolean requiresBase64Encoding(String s) {
131+
if (s.isEmpty()) {
132+
return false;
133+
}
134+
if (s.charAt(0) == ' ' || s.charAt(0) == '\t' || s.charAt(s.length() - 1) == ' '
135+
|| s.charAt(s.length() - 1) == '\t') {
136+
return true;
137+
}
138+
for (int i = 0; i < s.length(); i++) {
139+
char c = s.charAt(i);
140+
if (c < 0x20 || c > 0x7E) {
141+
return true;
142+
}
143+
}
144+
if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) {
145+
return true;
146+
}
147+
return false;
148+
}
149+
76150
}

mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ public static final class ErrorCodes {
158158
*/
159159
public static final int URL_ELICITATION_REQUIRED = -32042;
160160

161+
/**
162+
* An MCP HTTP header (Mcp-Method, Mcp-Name, or MCP-Protocol-Version) does not
163+
* match the JSON-RPC body.
164+
*/
165+
public static final int HEADER_MISMATCH = -32020;
166+
161167
}
162168

163169
/**

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,39 @@ void omitsMcpProtocolVersionHeaderOnInitializeRequest() throws IOException {
133133
}
134134
}
135135

136+
@Test
137+
void emitsBase64EncodedMcpNameForNonAsciiToolCall() throws IOException {
138+
var seenNames = new java.util.concurrent.CopyOnWriteArrayList<String>();
139+
var server = HttpServer.create(new InetSocketAddress(0), 0);
140+
141+
try {
142+
server.createContext("/mcp", exchange -> {
143+
seenNames.add(exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_NAME));
144+
exchange.getRequestBody().readAllBytes();
145+
exchange.sendResponseHeaders(202, -1);
146+
exchange.close();
147+
});
148+
server.start();
149+
150+
var transport = HttpClientStreamableHttpTransport
151+
.builder("http://localhost:" + server.getAddress().getPort())
152+
.endpoint("/mcp")
153+
.build();
154+
155+
try {
156+
var request = new McpSchema.CallToolRequest("計算機", Map.of(), null);
157+
var testMessage = new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_CALL, "test-id", request);
158+
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
159+
}
160+
finally {
161+
StepVerifier.create(transport.closeGracefully()).verifyComplete();
162+
}
163+
164+
assertThat(seenNames).contains("=?base64?6KiI566X5qmf?=");
165+
}
166+
finally {
167+
server.stop(0);
168+
}
169+
}
170+
136171
}

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ void streamableRejectsMcpMethodMismatch() throws Exception {
7070
var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_METHOD, "wrong/method"), toolCallBody("t"));
7171

7272
assertThat(resp.getStatus()).isEqualTo(400);
73-
assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch");
73+
assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch", "-32020");
7474
}
7575

7676
@Test
@@ -80,7 +80,17 @@ void streamableRejectsMcpNameMismatch() throws Exception {
8080
var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_NAME, "wrong-name"), toolCallBody("t"));
8181

8282
assertThat(resp.getStatus()).isEqualTo(400);
83-
assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch");
83+
assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch", "-32020");
84+
}
85+
86+
@Test
87+
void streamableAcceptsBase64EncodedMcpName() throws Exception {
88+
var provider = HttpServletStreamableServerTransportProvider.builder().mcpEndpoint("/mcp").build();
89+
90+
var resp = invoke(provider, "/mcp", Map.of(HttpHeaders.MCP_NAME, "=?base64?6KiI566X5qmf?="),
91+
toolCallBody("計算機"));
92+
93+
assertThat(resp.getContentAsString()).doesNotContain("Mcp-Name header mismatch");
8494
}
8595

8696
@Test
@@ -115,7 +125,7 @@ void statelessRejectsMcpMethodMismatch() throws Exception {
115125
var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_METHOD, "wrong/method"), toolCallBody("t"));
116126

117127
assertThat(resp.getStatus()).isEqualTo(400);
118-
assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch");
128+
assertThat(resp.getContentAsString()).contains("Mcp-Method header mismatch", "-32020");
119129
}
120130

121131
@Test
@@ -125,7 +135,17 @@ void statelessRejectsMcpNameMismatch() throws Exception {
125135
var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_NAME, "wrong-name"), toolCallBody("t"));
126136

127137
assertThat(resp.getStatus()).isEqualTo(400);
128-
assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch");
138+
assertThat(resp.getContentAsString()).contains("Mcp-Name header mismatch", "-32020");
139+
}
140+
141+
@Test
142+
void statelessAcceptsBase64EncodedMcpName() throws Exception {
143+
var transport = HttpServletStatelessServerTransport.builder().messageEndpoint("/mcp").build();
144+
145+
var resp = invoke(transport, "/mcp", Map.of(HttpHeaders.MCP_NAME, "=?base64?6KiI566X5qmf?="),
146+
toolCallBody("計算機"));
147+
148+
assertThat(resp.getContentAsString()).doesNotContain("Mcp-Name header mismatch");
129149
}
130150

131151
@Test

0 commit comments

Comments
 (0)