Skip to content

Commit e625931

Browse files
committed
Reject non application/json POST requests
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 73a9a8f commit e625931

8 files changed

Lines changed: 239 additions & 29 deletions

File tree

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
import java.io.ByteArrayOutputStream;
88
import java.io.IOException;
99
import java.io.InputStream;
10+
import java.io.PrintWriter;
1011
import java.nio.charset.StandardCharsets;
1112

13+
import io.modelcontextprotocol.json.McpJsonMapper;
14+
import io.modelcontextprotocol.spec.McpError;
15+
import io.modelcontextprotocol.spec.McpSchema;
1216
import jakarta.servlet.http.HttpServletRequest;
17+
import jakarta.servlet.http.HttpServletResponse;
1318

1419
/**
1520
* Utility methods for working with {@link HttpServletRequest}. For internal use only.
@@ -18,9 +23,53 @@
1823
*/
1924
final class HttpServletRequestUtils {
2025

26+
private static final String APPLICATION_JSON = "application/json";
27+
2128
private HttpServletRequestUtils() {
2229
}
2330

31+
/**
32+
* Checks whether a {@code Content-Type} header value denotes
33+
* {@code application/json}. Only the media type is compared, case-insensitively;
34+
* parameters such as {@code charset} are ignored. This is not a substring search, so
35+
* a value like {@code text/plain; a=application/json} is rejected.
36+
* <p>
37+
* Requiring {@code application/json} prevents browsers from sending cross-origin
38+
* JSON-RPC messages as CORS "simple requests" (e.g. with {@code text/plain}), which
39+
* would otherwise reach the server without a preflight.
40+
* @param contentType The {@code Content-Type} header value, may be {@code null}
41+
* @return {@code true} if the media type is {@code application/json}
42+
*/
43+
static boolean isJsonContentType(String contentType) {
44+
if (contentType == null) {
45+
return false;
46+
}
47+
int parametersStart = contentType.indexOf(';');
48+
String mediaType = parametersStart == -1 ? contentType : contentType.substring(0, parametersStart);
49+
return APPLICATION_JSON.equalsIgnoreCase(mediaType.trim());
50+
}
51+
52+
/**
53+
* Writes a {@code 415 Unsupported Media Type} response with a JSON-RPC error body,
54+
* for requests whose {@code Content-Type} is not {@code application/json}, see
55+
* {@link #isJsonContentType(String)}.
56+
* @param response The HTTP servlet response
57+
* @param jsonMapper The JSON mapper used to serialize the error
58+
* @throws IOException If an I/O error occurs while writing the response
59+
*/
60+
static void rejectUnsupportedContentType(HttpServletResponse response, McpJsonMapper jsonMapper)
61+
throws IOException {
62+
McpError mcpError = McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
63+
.message("Unsupported Media Type: Content-Type must be application/json")
64+
.build();
65+
response.setContentType(APPLICATION_JSON);
66+
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
67+
response.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
68+
PrintWriter writer = response.getWriter();
69+
writer.write(jsonMapper.writeValueAsString(mcpError));
70+
writer.flush();
71+
}
72+
2473
/**
2574
* Reads the request body, decoded using the request's character encoding (or UTF-8 if
2675
* not specified), while bounding the number of bytes read.

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

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

380+
if (!HttpServletRequestUtils.isJsonContentType(request.getContentType())) {
381+
HttpServletRequestUtils.rejectUnsupportedContentType(response, this.jsonMapper);
382+
return;
383+
}
384+
380385
// Get the session ID from the request parameter
381386
String sessionId = request.getParameter("sessionId");
382387
if (sessionId == null) {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
179179
return;
180180
}
181181

182+
if (!HttpServletRequestUtils.isJsonContentType(request.getContentType())) {
183+
HttpServletRequestUtils.rejectUnsupportedContentType(response, this.jsonMapper);
184+
return;
185+
}
186+
182187
try {
183188
String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
184189

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,11 @@ 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+
HttpServletRequestUtils.rejectUnsupportedContentType(response, this.jsonMapper);
508+
return;
509+
}
510+
506511
McpTransportContext transportContext = this.contextExtractor.extract(request);
507512

508513
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: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import java.nio.charset.StandardCharsets;
1313
import java.time.Duration;
1414
import java.util.Map;
15+
import java.util.concurrent.CompletableFuture;
16+
import java.util.concurrent.TimeUnit;
17+
import java.util.concurrent.atomic.AtomicBoolean;
1518
import java.util.stream.Stream;
1619

1720
import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests;
@@ -22,6 +25,7 @@
2225
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
2326
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
2427
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
28+
import io.modelcontextprotocol.spec.McpSchema;
2529
import jakarta.servlet.http.HttpServletRequest;
2630
import jakarta.servlet.http.HttpServletResponse;
2731
import org.apache.catalina.LifecycleException;
@@ -33,8 +37,12 @@
3337
import org.junit.jupiter.api.BeforeEach;
3438
import org.junit.jupiter.api.Test;
3539
import org.junit.jupiter.api.Timeout;
40+
import org.junit.jupiter.params.ParameterizedTest;
3641
import org.junit.jupiter.params.provider.Arguments;
42+
import org.junit.jupiter.params.provider.ValueSource;
43+
import reactor.core.publisher.Mono;
3744

45+
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
3846
import static org.assertj.core.api.Assertions.assertThat;
3947

4048
@Timeout(15)
@@ -125,36 +133,8 @@ protected void prepareClients(int port, String mcpEndpoint) {
125133
void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception {
126134
var httpClient = HttpClient.newHttpClient();
127135

128-
// Establish an SSE session to obtain a valid session ID
129136
prepareAsyncServerBuilder().build();
130-
var sseRequest = HttpRequest.newBuilder()
131-
.uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT))
132-
.header("Accept", "text/event-stream")
133-
.GET()
134-
.build();
135-
var sseResponseRef = new java.util.concurrent.atomic.AtomicReference<HttpResponse<java.io.InputStream>>();
136-
var sessionIdFuture = new java.util.concurrent.CompletableFuture<String>();
137-
httpClient.sendAsync(sseRequest, HttpResponse.BodyHandlers.ofInputStream()).thenAccept(response -> {
138-
sseResponseRef.set(response);
139-
try (var reader = new java.io.BufferedReader(
140-
new java.io.InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
141-
String line;
142-
while ((line = reader.readLine()) != null) {
143-
if (line.startsWith("data:") && line.contains("sessionId=")) {
144-
String data = line.substring("data:".length()).strip();
145-
String sessionId = data.substring(data.indexOf("sessionId=") + "sessionId=".length());
146-
sessionIdFuture.complete(sessionId);
147-
return;
148-
}
149-
}
150-
sessionIdFuture.completeExceptionally(new RuntimeException("sessionId not found in SSE stream"));
151-
response.body().close();
152-
}
153-
catch (Exception e) {
154-
sessionIdFuture.completeExceptionally(e);
155-
}
156-
});
157-
String sessionId = sessionIdFuture.get(5, java.util.concurrent.TimeUnit.SECONDS);
137+
String sessionId = openSseSession(httpClient);
158138

159139
// Send POST request with an over-sized body
160140
byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8);
@@ -194,6 +174,59 @@ public void cancel() {
194174
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
195175
}
196176

177+
@ParameterizedTest
178+
@ValueSource(strings = { "text/plain;charset=UTF-8", "application/x-www-form-urlencoded", "multipart/form-data" })
179+
void rejectsNonJsonContentType(String contentType) throws Exception {
180+
var httpClient = HttpClient.newHttpClient();
181+
var toolCalled = new AtomicBoolean();
182+
prepareAsyncServerBuilder().capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
183+
.tools(McpServerFeatures.AsyncToolSpecification.builder()
184+
.tool(McpSchema.Tool.builder("tool1", EMPTY_JSON_SCHEMA).build())
185+
.callHandler((exchange, request) -> {
186+
toolCalled.set(true);
187+
return Mono.just(McpSchema.CallToolResult.builder().build());
188+
})
189+
.build())
190+
.build();
191+
String sessionId = openSseSession(httpClient);
192+
193+
// CORS-safelisted content types can be sent cross-origin by a browser without a
194+
// preflight, so they must be rejected before the message is handled
195+
var request = HttpRequest.newBuilder()
196+
.uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT + "?sessionId=" + sessionId))
197+
.header("Content-Type", contentType)
198+
.POST(HttpRequest.BodyPublishers.ofString("""
199+
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"tool1","arguments":{}}}"""))
200+
.build();
201+
202+
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
203+
204+
assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
205+
assertThat(response.body()).contains("Unsupported Media Type: Content-Type must be application/json");
206+
assertThat(toolCalled).isFalse();
207+
}
208+
209+
/**
210+
* Opens an SSE connection and returns the session ID from the endpoint event. The
211+
* connection stays open, keeping the session alive, until the server closes it.
212+
*/
213+
private String openSseSession(HttpClient httpClient) throws Exception {
214+
var sseRequest = HttpRequest.newBuilder()
215+
.uri(URI.create("http://localhost:" + PORT + CUSTOM_SSE_ENDPOINT))
216+
.header("Accept", "text/event-stream")
217+
.GET()
218+
.build();
219+
var sessionIdFuture = new CompletableFuture<String>();
220+
httpClient.sendAsync(sseRequest, HttpResponse.BodyHandlers.ofLines())
221+
.thenAccept(response -> response.body().forEach(line -> {
222+
int index = line.indexOf("sessionId=");
223+
if (line.startsWith("data:") && index != -1) {
224+
sessionIdFuture.complete(line.substring(index + "sessionId=".length()).strip());
225+
}
226+
}));
227+
return sessionIdFuture.get(5, TimeUnit.SECONDS);
228+
}
229+
197230
static McpTransportContextExtractor<HttpServletRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
198231
.create(Map.of("important", "value"));
199232

‎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) {

0 commit comments

Comments
 (0)