Skip to content

Commit f856940

Browse files
authored
Reject non application/json POST requests (#1164)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 73a9a8f commit f856940

8 files changed

Lines changed: 231 additions & 1 deletion

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,32 @@
1818
*/
1919
final class HttpServletRequestUtils {
2020

21+
private static final String APPLICATION_JSON = "application/json";
22+
2123
private HttpServletRequestUtils() {
2224
}
2325

26+
/**
27+
* Checks whether a {@code Content-Type} header value denotes
28+
* {@code application/json}. Only the media type is compared, case-insensitively;
29+
* parameters such as {@code charset} are ignored. This is not a substring search, so
30+
* a value like {@code text/plain; a=application/json} is rejected.
31+
* <p>
32+
* Requiring {@code application/json} prevents browsers from sending cross-origin
33+
* JSON-RPC messages as CORS "simple requests" (e.g. with {@code text/plain}), which
34+
* would otherwise reach the server without a preflight.
35+
* @param contentType The {@code Content-Type} header value, may be {@code null}
36+
* @return {@code true} if the media type is {@code application/json}
37+
*/
38+
static boolean isJsonContentType(String contentType) {
39+
if (contentType == null) {
40+
return false;
41+
}
42+
int parametersStart = contentType.indexOf(';');
43+
String mediaType = parametersStart == -1 ? contentType : contentType.substring(0, parametersStart);
44+
return APPLICATION_JSON.equalsIgnoreCase(mediaType.trim());
45+
}
46+
2447
/**
2548
* Reads the request body, decoded using the request's character encoding (or UTF-8 if
2649
* not specified), while bounding the number of bytes read.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
377377
return;
378378
}
379379

380+
if (!HttpServletRequestUtils.isJsonContentType(request.getContentType())) {
381+
this.responseError(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE,
382+
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
383+
.message("Unsupported Media Type: Content-Type must be application/json")
384+
.build());
385+
return;
386+
}
387+
380388
// Get the session ID from the request parameter
381389
String sessionId = request.getParameter("sessionId");
382390
if (sessionId == null) {
@@ -481,6 +489,16 @@ private void sendEvent(PrintWriter writer, String eventType, String data) throws
481489
}
482490
}
483491

492+
private void responseError(HttpServletResponse response, int httpCode, McpError mcpError) throws IOException {
493+
response.setContentType(APPLICATION_JSON);
494+
response.setCharacterEncoding(UTF_8);
495+
response.setStatus(httpCode);
496+
String jsonError = jsonMapper.writeValueAsString(mcpError);
497+
PrintWriter writer = response.getWriter();
498+
writer.write(jsonError);
499+
writer.flush();
500+
}
501+
484502
/**
485503
* Cleans up resources when the servlet is being destroyed.
486504
* <p>

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,13 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
168168
return;
169169
}
170170

171-
McpTransportContext transportContext = this.contextExtractor.extract(request);
171+
if (!HttpServletRequestUtils.isJsonContentType(request.getContentType())) {
172+
this.responseError(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE,
173+
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
174+
.message("Unsupported Media Type: Content-Type must be application/json")
175+
.build());
176+
return;
177+
}
172178

173179
String accept = request.getHeader(ACCEPT);
174180
if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) {
@@ -179,6 +185,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
179185
return;
180186
}
181187

188+
McpTransportContext transportContext = this.contextExtractor.extract(request);
189+
182190
try {
183191
String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
184192

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,14 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
503503
badRequestErrors.add("application/json required in Accept header");
504504
}
505505

506+
if (!HttpServletRequestUtils.isJsonContentType(request.getContentType())) {
507+
this.responseError(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE,
508+
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
509+
.message("Unsupported Media Type: Content-Type must be application/json")
510+
.build());
511+
return;
512+
}
513+
506514
McpTransportContext transportContext = this.contextExtractor.extract(request);
507515

508516
try {

‎mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtilsTests.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import jakarta.servlet.ServletInputStream;
1313
import jakarta.servlet.http.HttpServletRequest;
1414
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.NullAndEmptySource;
17+
import org.junit.jupiter.params.provider.ValueSource;
1518

1619
import static org.assertj.core.api.Assertions.assertThat;
1720
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -100,6 +103,22 @@ void honorsRequestCharacterEncoding() throws Exception {
100103
assertThat(body).isEqualTo("café");
101104
}
102105

106+
@ParameterizedTest
107+
@ValueSource(strings = { "application/json", "application/json; charset=utf-8", "application/json;charset=UTF-8",
108+
"Application/JSON", " application/json ; charset=utf-8" })
109+
void acceptsJsonContentType(String contentType) {
110+
assertThat(HttpServletRequestUtils.isJsonContentType(contentType)).isTrue();
111+
}
112+
113+
@ParameterizedTest
114+
@NullAndEmptySource
115+
@ValueSource(strings = { "text/plain", "text/plain;charset=UTF-8", "text/plain; a=application/json",
116+
"application/x-www-form-urlencoded", "multipart/form-data", "application/json-seq", "application/jsonp",
117+
"application/json, text/plain", "text/event-stream" })
118+
void rejectsNonJsonContentType(String contentType) {
119+
assertThat(HttpServletRequestUtils.isJsonContentType(contentType)).isFalse();
120+
}
121+
103122
private static HttpServletRequest requestWithBody(String body, String characterEncoding) throws IOException {
104123
HttpServletRequest request = mock(HttpServletRequest.class);
105124
when(request.getInputStream()).thenReturn(servletInputStream(body.getBytes(StandardCharsets.UTF_8)));

‎mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
package io.modelcontextprotocol.server;
66

7+
import java.io.BufferedReader;
8+
import java.io.InputStream;
9+
import java.io.InputStreamReader;
710
import java.net.URI;
811
import java.net.http.HttpClient;
912
import java.net.http.HttpRequest;
@@ -12,6 +15,9 @@
1215
import java.nio.charset.StandardCharsets;
1316
import java.time.Duration;
1417
import java.util.Map;
18+
import java.util.concurrent.CompletableFuture;
19+
import java.util.concurrent.TimeUnit;
20+
import java.util.concurrent.atomic.AtomicBoolean;
1521
import java.util.stream.Stream;
1622

1723
import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests;
@@ -22,6 +28,7 @@
2228
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
2329
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
2430
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
31+
import io.modelcontextprotocol.spec.McpSchema;
2532
import jakarta.servlet.http.HttpServletRequest;
2633
import jakarta.servlet.http.HttpServletResponse;
2734
import org.apache.catalina.LifecycleException;
@@ -33,8 +40,12 @@
3340
import org.junit.jupiter.api.BeforeEach;
3441
import org.junit.jupiter.api.Test;
3542
import org.junit.jupiter.api.Timeout;
43+
import org.junit.jupiter.params.ParameterizedTest;
3644
import org.junit.jupiter.params.provider.Arguments;
45+
import org.junit.jupiter.params.provider.ValueSource;
46+
import reactor.core.publisher.Mono;
3747

48+
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
3849
import static org.assertj.core.api.Assertions.assertThat;
3950

4051
@Timeout(15)
@@ -194,6 +205,55 @@ public void cancel() {
194205
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
195206
}
196207

208+
@ParameterizedTest
209+
@ValueSource(strings = { "text/plain;charset=UTF-8", "application/x-www-form-urlencoded", "multipart/form-data" })
210+
void rejectsNonJsonContentType(String contentType) throws Exception {
211+
var httpClient = HttpClient.newHttpClient();
212+
var toolCalled = new AtomicBoolean();
213+
prepareAsyncServerBuilder().capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
214+
.tools(McpServerFeatures.AsyncToolSpecification.builder()
215+
.tool(McpSchema.Tool.builder("tool1", EMPTY_JSON_SCHEMA).build())
216+
.callHandler((exchange, request) -> {
217+
toolCalled.set(true);
218+
return Mono.just(McpSchema.CallToolResult.builder().build());
219+
})
220+
.build())
221+
.build();
222+
223+
// Establish an SSE session to obtain a valid session ID
224+
var sseRequest = HttpRequest.newBuilder()
225+
.uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT))
226+
.header("Accept", "text/event-stream")
227+
.GET()
228+
.build();
229+
HttpResponse<InputStream> sseResponse = httpClient.send(sseRequest, HttpResponse.BodyHandlers.ofInputStream());
230+
try (var reader = new BufferedReader(new InputStreamReader(sseResponse.body(), StandardCharsets.UTF_8))) {
231+
var sessionIdFuture = CompletableFuture.supplyAsync(() -> reader.lines()
232+
.filter(line -> line.startsWith("data:") && line.contains("sessionId="))
233+
.map(line -> line.substring(line.indexOf("sessionId=") + "sessionId=".length()).strip())
234+
.findFirst()
235+
.orElseThrow(() -> new IllegalStateException("sessionId not found in SSE stream")));
236+
String sessionId = sessionIdFuture.get(5, TimeUnit.SECONDS);
237+
238+
// CORS-safelisted content types can be sent cross-origin by a browser without
239+
// a
240+
// preflight, so they must be rejected before the message is handled
241+
var request = HttpRequest.newBuilder()
242+
.uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT + "?sessionId=" + sessionId))
243+
.header("Content-Type", contentType)
244+
.POST(HttpRequest.BodyPublishers.ofString(
245+
"""
246+
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"tool1","arguments":{}}}"""))
247+
.build();
248+
249+
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
250+
251+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
252+
assertThat(response.body()).contains("Unsupported Media Type: Content-Type must be application/json");
253+
assertThat(toolCalled).isFalse();
254+
}
255+
}
256+
197257
static McpTransportContextExtractor<HttpServletRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
198258
.create(Map.of("important", "value"));
199259

‎mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.time.Duration;
1414
import java.util.List;
1515
import java.util.Map;
16+
import java.util.concurrent.atomic.AtomicBoolean;
1617
import java.util.concurrent.atomic.AtomicReference;
1718
import java.util.function.BiFunction;
1819
import java.util.function.Function;
@@ -53,6 +54,8 @@
5354
import org.junit.jupiter.api.BeforeAll;
5455
import org.junit.jupiter.api.Test;
5556
import org.junit.jupiter.api.Timeout;
57+
import org.junit.jupiter.params.ParameterizedTest;
58+
import org.junit.jupiter.params.provider.ValueSource;
5659
import org.slf4j.LoggerFactory;
5760
import reactor.core.publisher.Mono;
5861
import reactor.test.StepVerifier;
@@ -915,6 +918,39 @@ public void cancel() {
915918
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
916919
}
917920

921+
@ParameterizedTest
922+
@ValueSource(strings = { "text/plain;charset=UTF-8", "application/x-www-form-urlencoded", "multipart/form-data" })
923+
void rejectsNonJsonContentType(String contentType) throws Exception {
924+
AtomicBoolean toolCalled = new AtomicBoolean();
925+
McpServer.sync(mcpStatelessServerTransport)
926+
.capabilities(ServerCapabilities.builder().tools(false).build())
927+
.tools(McpStatelessServerFeatures.SyncToolSpecification.builder()
928+
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).build())
929+
.callHandler((transportContext, request) -> {
930+
toolCalled.set(true);
931+
return CallToolResult.builder().build();
932+
})
933+
.build())
934+
.build();
935+
936+
// CORS-safelisted content types can be sent cross-origin by a browser without a
937+
// preflight, so they must be rejected before the message is handled
938+
var request = HttpRequest.newBuilder()
939+
.uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT))
940+
.header("Content-Type", contentType)
941+
.header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
942+
.POST(HttpRequest.BodyPublishers.ofString("""
943+
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"tool1","arguments":{}}}"""))
944+
.build();
945+
946+
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
947+
948+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
949+
assertThatJson(response.body()).inPath("message")
950+
.isEqualTo("Unsupported Media Type: Content-Type must be application/json");
951+
assertThat(toolCalled).isFalse();
952+
}
953+
918954
private double evaluateExpression(String expression) {
919955
// Simple expression evaluator for testing
920956
return switch (expression) {

‎mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.Queue;
1919
import java.util.concurrent.CompletableFuture;
2020
import java.util.concurrent.ConcurrentLinkedQueue;
21+
import java.util.concurrent.atomic.AtomicBoolean;
2122
import java.util.concurrent.atomic.AtomicReference;
2223
import java.util.function.Function;
2324
import java.util.stream.Stream;
@@ -47,7 +48,9 @@
4748
import org.junit.jupiter.api.BeforeEach;
4849
import org.junit.jupiter.api.Test;
4950
import org.junit.jupiter.api.Timeout;
51+
import org.junit.jupiter.params.ParameterizedTest;
5052
import org.junit.jupiter.params.provider.Arguments;
53+
import org.junit.jupiter.params.provider.ValueSource;
5154
import org.slf4j.LoggerFactory;
5255
import reactor.core.publisher.Flux;
5356
import reactor.core.publisher.Mono;
@@ -250,6 +253,61 @@ public void cancel() {
250253
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
251254
}
252255

256+
@ParameterizedTest
257+
@ValueSource(strings = { "text/plain;charset=UTF-8", "application/x-www-form-urlencoded", "multipart/form-data" })
258+
void rejectsInitializeWithNonJsonContentType(String contentType) throws Exception {
259+
prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
260+
261+
// CORS-safelisted content types can be sent cross-origin by a browser without a
262+
// preflight, so they must be rejected before a session is created
263+
var initialize = HttpRequest.newBuilder()
264+
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
265+
.header("Content-Type", contentType)
266+
.header("Accept", "text/event-stream, application/json")
267+
.POST(HttpRequest.BodyPublishers.ofString("""
268+
{"jsonrpc":"2.0","id":"init","method":"initialize","params":{
269+
"protocolVersion":"2025-06-18","capabilities":{},
270+
"clientInfo":{"name":"test-client","version":"1.0.0"}}}"""))
271+
.build();
272+
273+
var response = httpClient.send(initialize, HttpResponse.BodyHandlers.ofString());
274+
275+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
276+
assertThat(response.body()).contains("Unsupported Media Type: Content-Type must be application/json");
277+
assertThat(response.headers().firstValue(HttpHeaders.MCP_SESSION_ID)).isEmpty();
278+
}
279+
280+
@Test
281+
void rejectsToolCallWithNonJsonContentType() throws Exception {
282+
var toolCalled = new AtomicBoolean();
283+
prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
284+
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
285+
.tools(McpServerFeatures.AsyncToolSpecification.builder()
286+
.tool(McpSchema.Tool.builder("tool1", EMPTY_JSON_SCHEMA).build())
287+
.callHandler((exchange, request) -> {
288+
toolCalled.set(true);
289+
return Mono.just(McpSchema.CallToolResult.builder().build());
290+
})
291+
.build())
292+
.build();
293+
var sessionId = initializeSession(httpClient);
294+
295+
var toolCall = HttpRequest.newBuilder()
296+
.uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT))
297+
.header("Content-Type", "text/plain;charset=UTF-8")
298+
.header("Accept", "text/event-stream, application/json")
299+
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
300+
.POST(HttpRequest.BodyPublishers.ofString("""
301+
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"tool1","arguments":{}}}"""))
302+
.build();
303+
304+
var response = httpClient.send(toolCall, HttpResponse.BodyHandlers.ofString());
305+
306+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
307+
assertThat(response.body()).contains("Unsupported Media Type: Content-Type must be application/json");
308+
assertThat(toolCalled).isFalse();
309+
}
310+
253311
@Test
254312
void resumedStreamReceivesServerNotifications() {
255313
prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();

0 commit comments

Comments
 (0)