Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ public Mono<McpSchema.JSONRPCResponse> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,16 @@ private Mono<McpSchema.JSONRPCResponse> 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())));
}));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -37,4 +40,50 @@ void testHandleRequestWithUnregisteredMethod() {
}).verifyComplete();
}

@Test
void testHandleRequestWithEmptyHandlerResult() {
// handler that completes empty, without emitting a result or an error
Map<String, McpStatelessRequestHandler<?>> 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<String, McpStatelessRequestHandler<?>> 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();
}

}
Original file line number Diff line number Diff line change
@@ -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<McpSchema.JSONRPCMessage> messages = new ArrayList<>();

@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
this.messages.add(message);
return Mono.empty();
}

@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}

@Override
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return null;
}

}

private McpServerSession createSession(RecordingTransport transport,
Map<String, McpRequestHandler<?>> 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<String, McpRequestHandler<?>> 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<String, McpRequestHandler<?>> 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<String, McpRequestHandler<?>> 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");
}

}