diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java index 5cd5de7ad..4021c1690 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java @@ -48,7 +48,15 @@ public Mono handleRequest(McpTransportContext transpo t.getMessage()); } return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), error)); - }); + }) + .switchIfEmpty(Mono.defer(() -> { + logger.warn("Request handler for method '{}' completed without producing a result. " + + "Responding with an internal error to honor JSON-RPC 2.0's " + + "one-response-per-request contract.", request.method()); + return Mono.just(McpSchema.JSONRPCResponse + .error(request.id(), new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + "Request handler completed without producing a result for method: " + request.method()))); + })); } @Override diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 8f86138f0..b2e9a03e5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -307,7 +307,16 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), McpError.aggregateExceptionMessages(error)); return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError)); - }); + }) + .switchIfEmpty(Mono.defer(() -> { + logger.warn("Request handler for method '{}' completed without producing a result. " + + "Responding with an internal error to honor JSON-RPC 2.0's " + + "one-response-per-request contract.", request.method()); + return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + "Request handler completed without producing a result for method: " + + request.method()))); + })); }); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java index 267aca504..cb449bfbe 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandlerTests.java @@ -7,9 +7,12 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -37,4 +40,50 @@ void testHandleRequestWithUnregisteredMethod() { }).verifyComplete(); } + @Test + void testHandleRequestWithEmptyHandlerResult() { + // handler that completes empty, without emitting a result or an error + Map> handlers = new HashMap<>(); + handlers.put("custom/empty", (transportContext, params) -> Mono.empty()); + DefaultMcpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(handlers, + Collections.emptyMap()); + + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/empty", + "test-id-456", null); + + StepVerifier.create(handler.handleRequest(McpTransportContext.EMPTY, request)).assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo("test-id-456"); + assertThat(response.result()).isNull(); + + // an empty completion must still yield exactly one JSON-RPC error response + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).contains("without producing a result"); + }).verifyComplete(); + } + + @Test + void testHandleRequestWithHandlerError() { + // handler that fails with an exception; the error must still be converted into a + // JSON-RPC error response + Map> handlers = new HashMap<>(); + handlers.put("custom/failing", (transportContext, params) -> Mono.error(new IllegalStateException("boom"))); + DefaultMcpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(handlers, + Collections.emptyMap()); + + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/failing", + "test-id-789", null); + + StepVerifier.create(handler.handleRequest(McpTransportContext.EMPTY, request)).assertNext(response -> { + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo("test-id-789"); + assertThat(response.result()).isNull(); + + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).isEqualTo("boom"); + }).verifyComplete(); + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpServerSessionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpServerSessionTests.java new file mode 100644 index 000000000..6eb8cf9f1 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpServerSessionTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpRequestHandler; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link McpServerSession} request dispatch. + */ +class McpServerSessionTests { + + private static class RecordingTransport implements McpServerTransport { + + final List messages = new ArrayList<>(); + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + this.messages.add(message); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return null; + } + + } + + private McpServerSession createSession(RecordingTransport transport, + Map> requestHandlers) { + return new McpServerSession("session-1", Duration.ofSeconds(5), transport, initializeRequest -> Mono.empty(), + requestHandlers, new HashMap<>()); + } + + /** + * Populates the exchange sink, which {@link McpServerSession} requires before it can + * dispatch non-initialize requests to the registered request handlers. + */ + private void initializeSession(McpServerSession session) { + session.handle(new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED)).block(); + } + + @Test + void handleSendsErrorResponseWhenRequestHandlerCompletesEmpty() { + RecordingTransport transport = new RecordingTransport(); + Map> requestHandlers = new HashMap<>(); + requestHandlers.put("custom/empty", (exchange, params) -> Mono.empty()); + + McpServerSession session = createSession(transport, requestHandlers); + initializeSession(session); + + session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/empty", "req-1", null)).block(); + + // an empty handler completion must still produce exactly one JSON-RPC response + assertThat(transport.messages).hasSize(1); + assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0); + assertThat(response.id()).isEqualTo("req-1"); + assertThat(response.result()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).contains("without producing a result"); + } + + @Test + void handleSendsResultWhenRequestHandlerCompletesNormally() { + RecordingTransport transport = new RecordingTransport(); + Map> requestHandlers = new HashMap<>(); + requestHandlers.put("custom/echo", (exchange, params) -> Mono.just("pong")); + + McpServerSession session = createSession(transport, requestHandlers); + initializeSession(session); + + session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/echo", "req-2", null)).block(); + + assertThat(transport.messages).hasSize(1); + assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0); + assertThat(response.id()).isEqualTo("req-2"); + assertThat(response.result()).isEqualTo("pong"); + assertThat(response.error()).isNull(); + } + + @Test + void handleSendsErrorResponseWhenRequestHandlerFails() { + RecordingTransport transport = new RecordingTransport(); + Map> requestHandlers = new HashMap<>(); + requestHandlers.put("custom/failing", (exchange, params) -> Mono.error(new IllegalStateException("boom"))); + + McpServerSession session = createSession(transport, requestHandlers); + initializeSession(session); + + session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/failing", "req-3", null)) + .block(); + + assertThat(transport.messages).hasSize(1); + assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0); + assertThat(response.id()).isEqualTo("req-3"); + assertThat(response.result()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).isEqualTo("boom"); + } + +}