|
4 | 4 |
|
5 | 5 | package io.modelcontextprotocol.server; |
6 | 6 |
|
| 7 | +import java.io.BufferedReader; |
| 8 | +import java.io.InputStream; |
| 9 | +import java.io.InputStreamReader; |
7 | 10 | import java.net.URI; |
8 | 11 | import java.net.http.HttpClient; |
9 | 12 | import java.net.http.HttpRequest; |
|
12 | 15 | import java.nio.charset.StandardCharsets; |
13 | 16 | import java.time.Duration; |
14 | 17 | import java.util.Map; |
| 18 | +import java.util.concurrent.CompletableFuture; |
| 19 | +import java.util.concurrent.TimeUnit; |
| 20 | +import java.util.concurrent.atomic.AtomicBoolean; |
15 | 21 | import java.util.stream.Stream; |
16 | 22 |
|
17 | 23 | import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; |
|
22 | 28 | import io.modelcontextprotocol.server.McpServer.SyncSpecification; |
23 | 29 | import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; |
24 | 30 | import io.modelcontextprotocol.server.transport.TomcatTestUtil; |
| 31 | +import io.modelcontextprotocol.spec.McpSchema; |
25 | 32 | import jakarta.servlet.http.HttpServletRequest; |
26 | 33 | import jakarta.servlet.http.HttpServletResponse; |
27 | 34 | import org.apache.catalina.LifecycleException; |
|
33 | 40 | import org.junit.jupiter.api.BeforeEach; |
34 | 41 | import org.junit.jupiter.api.Test; |
35 | 42 | import org.junit.jupiter.api.Timeout; |
| 43 | +import org.junit.jupiter.params.ParameterizedTest; |
36 | 44 | import org.junit.jupiter.params.provider.Arguments; |
| 45 | +import org.junit.jupiter.params.provider.ValueSource; |
| 46 | +import reactor.core.publisher.Mono; |
37 | 47 |
|
| 48 | +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; |
38 | 49 | import static org.assertj.core.api.Assertions.assertThat; |
39 | 50 |
|
40 | 51 | @Timeout(15) |
@@ -194,6 +205,55 @@ public void cancel() { |
194 | 205 | assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); |
195 | 206 | } |
196 | 207 |
|
| 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 | + |
197 | 257 | static McpTransportContextExtractor<HttpServletRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext |
198 | 258 | .create(Map.of("important", "value")); |
199 | 259 |
|
|
0 commit comments