Skip to content

Commit b9ec3d8

Browse files
committed
Send JSON-RPC error response when a request handler completes empty
When a server-side request handler returns a Mono that completes empty, McpServerSession.handleIncomingRequest and DefaultMcpStatelessServerHandler.handleRequest propagated the empty completion, so no JSON-RPC response was ever sent for the request. This violates JSON-RPC 2.0's one-response-per-request contract and leaves the client waiting indefinitely. Both dispatch paths now complete with an internal error (-32603) response when the handler completes without a result, and log a warning naming the method. Normal result and error behavior is unchanged. Adds regression tests for the empty-completion, normal-result and error paths in both the stateful (McpServerSession) and stateless (DefaultMcpStatelessServerHandler) dispatch layers. Fixes #1081 Signed-off-by: zhaoyuzhe <1991039819@qq.com>
1 parent 8ee8ccb commit b9ec3d8

4 files changed

Lines changed: 192 additions & 2 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,15 @@ public Mono<McpSchema.JSONRPCResponse> handleRequest(McpTransportContext transpo
4848
t.getMessage());
4949
}
5050
return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), error));
51-
});
51+
})
52+
.switchIfEmpty(Mono.defer(() -> {
53+
logger.warn("Request handler for method '{}' completed without producing a result. "
54+
+ "Responding with an internal error to honor JSON-RPC 2.0's "
55+
+ "one-response-per-request contract.", request.method());
56+
return Mono.just(McpSchema.JSONRPCResponse
57+
.error(request.id(), new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
58+
"Request handler completed without producing a result for method: " + request.method())));
59+
}));
5260
}
5361

5462
@Override

‎mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,16 @@ private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCR
307307
: new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
308308
error.getMessage(), McpError.aggregateExceptionMessages(error));
309309
return Mono.just(McpSchema.JSONRPCResponse.error(request.id(), jsonRpcError));
310-
});
310+
})
311+
.switchIfEmpty(Mono.defer(() -> {
312+
logger.warn("Request handler for method '{}' completed without producing a result. "
313+
+ "Responding with an internal error to honor JSON-RPC 2.0's "
314+
+ "one-response-per-request contract.", request.method());
315+
return Mono.just(McpSchema.JSONRPCResponse.error(request.id(),
316+
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
317+
"Request handler completed without producing a result for method: "
318+
+ request.method())));
319+
}));
311320
});
312321
}
313322

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import io.modelcontextprotocol.common.McpTransportContext;
88
import io.modelcontextprotocol.spec.McpSchema;
99
import org.junit.jupiter.api.Test;
10+
import reactor.core.publisher.Mono;
1011
import reactor.test.StepVerifier;
1112

1213
import java.util.Collections;
14+
import java.util.HashMap;
15+
import java.util.Map;
1316

1417
import static org.assertj.core.api.Assertions.assertThat;
1518

@@ -37,4 +40,50 @@ void testHandleRequestWithUnregisteredMethod() {
3740
}).verifyComplete();
3841
}
3942

43+
@Test
44+
void testHandleRequestWithEmptyHandlerResult() {
45+
// handler that completes empty, without emitting a result or an error
46+
Map<String, McpStatelessRequestHandler<?>> handlers = new HashMap<>();
47+
handlers.put("custom/empty", (transportContext, params) -> Mono.empty());
48+
DefaultMcpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(handlers,
49+
Collections.emptyMap());
50+
51+
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/empty",
52+
"test-id-456", null);
53+
54+
StepVerifier.create(handler.handleRequest(McpTransportContext.EMPTY, request)).assertNext(response -> {
55+
assertThat(response).isNotNull();
56+
assertThat(response.id()).isEqualTo("test-id-456");
57+
assertThat(response.result()).isNull();
58+
59+
// an empty completion must still yield exactly one JSON-RPC error response
60+
assertThat(response.error()).isNotNull();
61+
assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
62+
assertThat(response.error().message()).contains("without producing a result");
63+
}).verifyComplete();
64+
}
65+
66+
@Test
67+
void testHandleRequestWithHandlerError() {
68+
// handler that fails with an exception; the error must still be converted into a
69+
// JSON-RPC error response
70+
Map<String, McpStatelessRequestHandler<?>> handlers = new HashMap<>();
71+
handlers.put("custom/failing", (transportContext, params) -> Mono.error(new IllegalStateException("boom")));
72+
DefaultMcpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(handlers,
73+
Collections.emptyMap());
74+
75+
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/failing",
76+
"test-id-789", null);
77+
78+
StepVerifier.create(handler.handleRequest(McpTransportContext.EMPTY, request)).assertNext(response -> {
79+
assertThat(response).isNotNull();
80+
assertThat(response.id()).isEqualTo("test-id-789");
81+
assertThat(response.result()).isNull();
82+
83+
assertThat(response.error()).isNotNull();
84+
assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
85+
assertThat(response.error().message()).isEqualTo("boom");
86+
}).verifyComplete();
87+
}
88+
4089
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.spec;
6+
7+
import java.time.Duration;
8+
import java.util.ArrayList;
9+
import java.util.HashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
import io.modelcontextprotocol.json.TypeRef;
14+
import io.modelcontextprotocol.server.McpRequestHandler;
15+
import org.junit.jupiter.api.Test;
16+
import reactor.core.publisher.Mono;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
/**
21+
* Unit tests for {@link McpServerSession} request dispatch.
22+
*/
23+
class McpServerSessionTests {
24+
25+
private static class RecordingTransport implements McpServerTransport {
26+
27+
final List<McpSchema.JSONRPCMessage> messages = new ArrayList<>();
28+
29+
@Override
30+
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
31+
this.messages.add(message);
32+
return Mono.empty();
33+
}
34+
35+
@Override
36+
public Mono<Void> closeGracefully() {
37+
return Mono.empty();
38+
}
39+
40+
@Override
41+
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
42+
return null;
43+
}
44+
45+
}
46+
47+
private McpServerSession createSession(RecordingTransport transport,
48+
Map<String, McpRequestHandler<?>> requestHandlers) {
49+
return new McpServerSession("session-1", Duration.ofSeconds(5), transport, initializeRequest -> Mono.empty(),
50+
requestHandlers, new HashMap<>());
51+
}
52+
53+
/**
54+
* Populates the exchange sink, which {@link McpServerSession} requires before it can
55+
* dispatch non-initialize requests to the registered request handlers.
56+
*/
57+
private void initializeSession(McpServerSession session) {
58+
session.handle(new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED)).block();
59+
}
60+
61+
@Test
62+
void handleSendsErrorResponseWhenRequestHandlerCompletesEmpty() {
63+
RecordingTransport transport = new RecordingTransport();
64+
Map<String, McpRequestHandler<?>> requestHandlers = new HashMap<>();
65+
requestHandlers.put("custom/empty", (exchange, params) -> Mono.empty());
66+
67+
McpServerSession session = createSession(transport, requestHandlers);
68+
initializeSession(session);
69+
70+
session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/empty", "req-1", null)).block();
71+
72+
// an empty handler completion must still produce exactly one JSON-RPC response
73+
assertThat(transport.messages).hasSize(1);
74+
assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class);
75+
McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0);
76+
assertThat(response.id()).isEqualTo("req-1");
77+
assertThat(response.result()).isNull();
78+
assertThat(response.error()).isNotNull();
79+
assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
80+
assertThat(response.error().message()).contains("without producing a result");
81+
}
82+
83+
@Test
84+
void handleSendsResultWhenRequestHandlerCompletesNormally() {
85+
RecordingTransport transport = new RecordingTransport();
86+
Map<String, McpRequestHandler<?>> requestHandlers = new HashMap<>();
87+
requestHandlers.put("custom/echo", (exchange, params) -> Mono.just("pong"));
88+
89+
McpServerSession session = createSession(transport, requestHandlers);
90+
initializeSession(session);
91+
92+
session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/echo", "req-2", null)).block();
93+
94+
assertThat(transport.messages).hasSize(1);
95+
assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class);
96+
McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0);
97+
assertThat(response.id()).isEqualTo("req-2");
98+
assertThat(response.result()).isEqualTo("pong");
99+
assertThat(response.error()).isNull();
100+
}
101+
102+
@Test
103+
void handleSendsErrorResponseWhenRequestHandlerFails() {
104+
RecordingTransport transport = new RecordingTransport();
105+
Map<String, McpRequestHandler<?>> requestHandlers = new HashMap<>();
106+
requestHandlers.put("custom/failing", (exchange, params) -> Mono.error(new IllegalStateException("boom")));
107+
108+
McpServerSession session = createSession(transport, requestHandlers);
109+
initializeSession(session);
110+
111+
session.handle(new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "custom/failing", "req-3", null))
112+
.block();
113+
114+
assertThat(transport.messages).hasSize(1);
115+
assertThat(transport.messages.get(0)).isInstanceOf(McpSchema.JSONRPCResponse.class);
116+
McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) transport.messages.get(0);
117+
assertThat(response.id()).isEqualTo("req-3");
118+
assertThat(response.result()).isNull();
119+
assertThat(response.error()).isNotNull();
120+
assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
121+
assertThat(response.error().message()).isEqualTo("boom");
122+
}
123+
124+
}

0 commit comments

Comments
 (0)