diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorWriter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorWriter.java new file mode 100644 index 000000000..53084a759 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorWriter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.io.IOException; +import java.io.PrintWriter; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +import jakarta.servlet.http.HttpServletResponse; + +/** + * Writes JSON-RPC error response bodies for servlet transports. + * + * @author Taewoong Kim + */ +final class HttpServletJsonRpcErrorWriter { + + private static final String UTF_8 = "UTF-8"; + + private static final String APPLICATION_JSON = "application/json"; + + private HttpServletJsonRpcErrorWriter() { + } + + static void writeError(McpJsonMapper jsonMapper, HttpServletResponse response, int httpStatus, Object requestId, + McpError mcpError) throws IOException { + writeError(jsonMapper, response, httpStatus, requestId, mcpError.getJsonRpcError()); + } + + static void writeError(McpJsonMapper jsonMapper, HttpServletResponse response, int httpStatus, Object requestId, + McpSchema.JSONRPCResponse.JSONRPCError error) throws IOException { + response.setContentType(APPLICATION_JSON); + response.setCharacterEncoding(UTF_8); + response.setStatus(httpStatus); + + String jsonErrorResponse = jsonMapper.writeValueAsString(jsonRpcErrorResponse(requestId, error)); + PrintWriter writer = response.getWriter(); + writer.write(jsonErrorResponse); + writer.flush(); + } + + private static Object jsonRpcErrorResponse(Object requestId, McpSchema.JSONRPCResponse.JSONRPCError error) { + if (requestId != null) { + return McpSchema.JSONRPCResponse.error(requestId, error); + } + + // McpSchema.JSONRPCResponse requires a non-null id, but servlet transport + // errors can be generated before a JSON-RPC request id is available. The MCP + // JSONRPCErrorResponse schema permits omitting id in that case. + return new JsonRpcErrorResponse(McpSchema.JSONRPC_VERSION, error); + } + + private record JsonRpcErrorResponse(String jsonrpc, McpSchema.JSONRPCResponse.JSONRPCError error) { + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 05dd862e9..6bdd8f132 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -382,38 +382,30 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) // Get the session ID from the request parameter String sessionId = request.getParameter("sessionId"); if (sessionId == null) { - response.setContentType(APPLICATION_JSON); - response.setCharacterEncoding(UTF_8); - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - String jsonError = jsonMapper.writeValueAsString(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) - .message("Session ID missing in message endpoint") - .build()); - PrintWriter writer = response.getWriter(); - writer.write(jsonError); - writer.flush(); + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, null, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Session ID missing in message endpoint") + .build()); return; } // Get the session from the sessions map McpServerSession session = sessions.get(sessionId); if (session == null) { - response.setContentType(APPLICATION_JSON); - response.setCharacterEncoding(UTF_8); - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - String jsonError = jsonMapper.writeValueAsString(McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) - .message("Session not found: " + sessionId) - .build()); - PrintWriter writer = response.getWriter(); - writer.write(jsonError); - writer.flush(); + this.responseError(response, HttpServletResponse.SC_NOT_FOUND, null, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Session not found: " + sessionId) + .build()); return; } + Object requestId = null; try { String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize); final McpTransportContext transportContext = this.contextExtractor.extract(request); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); + requestId = requestId(message); // Process the message through the session's handle method // Block for Servlet compatibility @@ -430,13 +422,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) McpError mcpError = McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) .message(e.getMessage()) .build(); - response.setContentType(APPLICATION_JSON); - response.setCharacterEncoding(UTF_8); - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - String jsonError = jsonMapper.writeValueAsString(mcpError); - PrintWriter writer = response.getWriter(); - writer.write(jsonError); - writer.flush(); + this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, requestId, mcpError); } catch (IOException ex) { logger.error(FAILED_TO_SEND_ERROR_RESPONSE, ex.getMessage()); @@ -483,6 +469,15 @@ private void sendEvent(PrintWriter writer, String eventType, String data) throws } } + private void responseError(HttpServletResponse response, int httpCode, Object requestId, McpError mcpError) + throws IOException { + HttpServletJsonRpcErrorWriter.writeError(this.jsonMapper, response, httpCode, requestId, mcpError); + } + + private static Object requestId(McpSchema.JSONRPCMessage message) { + return (message instanceof McpSchema.JSONRPCRequest request) ? request.id() : null; + } + /** * Cleans up resources when the servlet is being destroyed. *
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
index 54f0ac030..fd21195ba 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
@@ -204,7 +204,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
}
catch (Exception e) {
logger.error("Failed to handle request: {}", e.getMessage());
- this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, jsonrpcRequest.id(),
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Failed to handle request: " + e.getMessage())
.build());
@@ -257,13 +257,12 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
* @throws IOException If an I/O error occurs
*/
private void responseError(HttpServletResponse response, int httpCode, McpError mcpError) throws IOException {
- response.setContentType(APPLICATION_JSON);
- response.setCharacterEncoding(UTF_8);
- response.setStatus(httpCode);
- String jsonError = jsonMapper.writeValueAsString(mcpError);
- PrintWriter writer = response.getWriter();
- writer.write(jsonError);
- writer.flush();
+ this.responseError(response, httpCode, null, mcpError);
+ }
+
+ private void responseError(HttpServletResponse response, int httpCode, Object requestId, McpError mcpError)
+ throws IOException {
+ HttpServletJsonRpcErrorWriter.writeError(this.jsonMapper, response, httpCode, requestId, mcpError);
}
/**
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index 324a2ecd3..e191e2bef 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -434,17 +434,18 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
McpTransportContext transportContext = this.contextExtractor.extract(request);
+ McpSchema.JSONRPCMessage message = null;
try {
String body = HttpServletRequestUtils.readBody(request, this.requestMaxSize);
- McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
+ message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
// Handle initialization request
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
&& jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) {
if (!badRequestErrors.isEmpty()) {
String combinedMessage = String.join("; ", badRequestErrors);
- this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
+ this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, jsonrpcRequest.id(),
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(combinedMessage).build());
return;
}
@@ -474,7 +475,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
}
catch (Exception e) {
logger.error("Failed to initialize session: {}", e.getMessage());
- this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, jsonrpcRequest.id(),
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Failed to initialize session: " + e.getMessage())
.build());
@@ -490,7 +491,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
if (!badRequestErrors.isEmpty()) {
String combinedMessage = String.join("; ", badRequestErrors);
- this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
+ this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, requestId(message),
McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(combinedMessage).build());
return;
}
@@ -498,7 +499,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
McpStreamableServerSession session = this.sessions.get(sessionId);
if (session == null) {
- this.responseError(response, HttpServletResponse.SC_NOT_FOUND,
+ this.responseError(response, HttpServletResponse.SC_NOT_FOUND, requestId(message),
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Session not found: " + sessionId)
.build());
@@ -541,7 +542,7 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
}
}
else {
- this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, requestId(message),
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message type").build());
}
}
@@ -550,7 +551,7 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
}
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
- this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
+ this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, requestId(message),
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Invalid message format: " + e.getMessage())
.build());
@@ -558,7 +559,7 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
catch (Exception e) {
logger.error("Error handling message: {}", e.getMessage());
try {
- this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, requestId(message),
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Error processing message: " + e.getMessage())
.build());
@@ -643,14 +644,16 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response
}
public void responseError(HttpServletResponse response, int httpCode, McpError mcpError) throws IOException {
- response.setContentType(APPLICATION_JSON);
- response.setCharacterEncoding(UTF_8);
- response.setStatus(httpCode);
- String jsonError = jsonMapper.writeValueAsString(mcpError);
- PrintWriter writer = response.getWriter();
- writer.write(jsonError);
- writer.flush();
- return;
+ this.responseError(response, httpCode, null, mcpError);
+ }
+
+ private void responseError(HttpServletResponse response, int httpCode, Object requestId, McpError mcpError)
+ throws IOException {
+ HttpServletJsonRpcErrorWriter.writeError(this.jsonMapper, response, httpCode, requestId, mcpError);
+ }
+
+ private static Object requestId(McpSchema.JSONRPCMessage message) {
+ return (message instanceof McpSchema.JSONRPCRequest request) ? request.id() : null;
}
/**
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorTests.java
new file mode 100644
index 000000000..41f346fcf
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletJsonRpcErrorTests.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.io.ByteArrayInputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
+import io.modelcontextprotocol.server.McpStatelessServerHandler;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.HttpHeaders;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpServerSession;
+import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper;
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Taewoong Kim
+ */
+class HttpServletJsonRpcErrorTests {
+
+ private static final McpJsonMapper JSON_MAPPER = new GsonMcpJsonMapper();
+
+ @Test
+ void statelessTransportMapsEscapedMcpErrorsToJsonRpcInternalError() throws Exception {
+ HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder()
+ .jsonMapper(JSON_MAPPER)
+ .build();
+ transport.setMcpHandler(
+ handlerReturning(request -> Mono.error(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND)
+ .message("Missing handler for request type: " + request.method())
+ .build())));
+ StringWriter responseBody = new StringWriter();
+ HttpServletResponse response = response(responseBody);
+
+ transport.doPost(request("""
+ {"jsonrpc":"2.0","id":"missing-handler","method":"missing/method"}
+ """, HttpServletStatelessServerTransport.APPLICATION_JSON + ", "
+ + HttpServletStatelessServerTransport.TEXT_EVENT_STREAM), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ McpSchema.JSONRPCResponse jsonResponse = readResponse(responseBody);
+ assertThat(jsonResponse.id()).isEqualTo("missing-handler");
+ assertThat(jsonResponse.error()).isNotNull();
+ assertThat(jsonResponse.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
+ assertNoThrowableFields(responseBody);
+ }
+
+ @Test
+ void statelessTransportMapsUnexpectedRequestErrorsToJsonRpcInternalError() throws Exception {
+ HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder()
+ .jsonMapper(JSON_MAPPER)
+ .build();
+ transport.setMcpHandler(handlerReturning(request -> Mono.error(new IllegalStateException("boom"))));
+ StringWriter responseBody = new StringWriter();
+ HttpServletResponse response = response(responseBody);
+
+ transport.doPost(request("""
+ {"jsonrpc":"2.0","id":"boom-request","method":"tools/list"}
+ """, HttpServletStatelessServerTransport.APPLICATION_JSON + ", "
+ + HttpServletStatelessServerTransport.TEXT_EVENT_STREAM), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ McpSchema.JSONRPCResponse jsonResponse = readResponse(responseBody);
+ assertThat(jsonResponse.id()).isEqualTo("boom-request");
+ assertThat(jsonResponse.error()).isNotNull();
+ assertThat(jsonResponse.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
+ assertNoThrowableFields(responseBody);
+ }
+
+ @Test
+ void statelessTransportSerializesTransportErrorsAsJsonRpcResponses() throws Exception {
+ HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder()
+ .jsonMapper(JSON_MAPPER)
+ .build();
+ StringWriter responseBody = new StringWriter();
+ HttpServletResponse response = response(responseBody);
+
+ transport.doPost(request("""
+ {"jsonrpc":"2.0","id":"missing-accept","method":"tools/list"}
+ """, null), response);
+
+ verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ Map