From 12038b36345ce32cbe6ad230805d419f822be982 Mon Sep 17 00:00:00 2001 From: Maxim Ceban Date: Thu, 6 Nov 2025 11:37:51 -0500 Subject: [PATCH] Enable request timeout when handling requests --- .../DefaultMcpStatelessServerHandler.java | 9 +- .../server/McpStatelessAsyncServer.java | 6 +- .../HttpServletStatelessIntegrationTests.java | 97 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) 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 d1b55f594..9f5a1a21b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java @@ -10,7 +10,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import java.time.Duration; import java.util.Map; class DefaultMcpStatelessServerHandler implements McpStatelessServerHandler { @@ -21,10 +23,13 @@ class DefaultMcpStatelessServerHandler implements McpStatelessServerHandler { Map notificationHandlers; + Duration requestTimeout; + public DefaultMcpStatelessServerHandler(Map> requestHandlers, - Map notificationHandlers) { + Map notificationHandlers, Duration requestTimeout) { this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; + this.requestTimeout = requestTimeout; } @Override @@ -35,6 +40,8 @@ public Mono handleRequest(McpTransportContext transpo return Mono.error(new McpError("Missing handler for request type: " + request.method())); } return requestHandler.handle(transportContext, request.params()) + .subscribeOn(Schedulers.boundedElastic()) + .timeout(this.requestTimeout) .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)) .onErrorResume(t -> { McpSchema.JSONRPCResponse.JSONRPCError error; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 997df7225..653b10662 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -78,6 +78,8 @@ public class McpStatelessAsyncServer { private final JsonSchemaValidator jsonSchemaValidator; + private final Duration requestTimeout; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) { @@ -93,6 +95,7 @@ public class McpStatelessAsyncServer { this.completions.putAll(features.completions()); this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; + this.requestTimeout = requestTimeout; Map> requestHandlers = new HashMap<>(); @@ -129,7 +132,8 @@ public class McpStatelessAsyncServer { this.protocolVersions = new ArrayList<>(mcpTransport.protocolVersions()); - McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, Map.of()); + McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, Map.of(), + this.requestTimeout); mcpTransport.setMcpHandler(handler); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index de74bafc1..a487ed69e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -647,4 +647,101 @@ private double evaluateExpression(String expression) { }; } + // --------------------------------------- + // Timeout Tests + // --------------------------------------- + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testRequestTimeoutWithSlowTool(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + + // Create a tool that takes longer than the timeout + McpStatelessServerFeatures.SyncToolSpecification slowTool = new McpStatelessServerFeatures.SyncToolSpecification( + Tool.builder() + .name("slow-tool") + .description("A tool that takes too long") + .inputSchema(EMPTY_JSON_SCHEMA) + .build(), + (transportContext, request) -> { + try { + // Sleep for 3 seconds, which is longer than our timeout + Thread.sleep(3000); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted", e); + } + return new CallToolResult(List.of(new TextContent("This should not be reached")), null); + }); + + // Create server with a 1-second request timeout + var mcpServer = McpServer.sync(mcpStatelessServerTransport) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .requestTimeout(Duration.ofSeconds(1)) + .tools(slowTool) + .build(); + + try (var mcpClient = clientBuilder.build()) { + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // Call the slow tool - should timeout and throw an exception + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> mcpClient.callTool(new McpSchema.CallToolRequest("slow-tool", Map.of()))) + .isInstanceOf(io.modelcontextprotocol.spec.McpError.class) + .satisfies(error -> { + String message = error.getMessage().toLowerCase(); + assertThat(message).containsAnyOf("timeout", "timed out", "did not observe"); + }); + } + finally { + mcpServer.close(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient" }) + void testRequestTimeoutWithFastTool(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + + // Create a tool that completes quickly + McpStatelessServerFeatures.SyncToolSpecification fastTool = new McpStatelessServerFeatures.SyncToolSpecification( + Tool.builder() + .name("fast-tool") + .description("A tool that completes quickly") + .inputSchema(EMPTY_JSON_SCHEMA) + .build(), + (transportContext, request) -> { + return new CallToolResult(List.of(new TextContent("Fast response")), null); + }); + + // Create server with a 5-second request timeout (plenty of time) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .requestTimeout(Duration.ofSeconds(5)) + .tools(fastTool) + .build(); + + try (var mcpClient = clientBuilder.build()) { + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // Call the fast tool - should succeed + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("fast-tool", Map.of())); + + // Verify that we got a successful response + assertThat(response).isNotNull(); + assertThat(response.isError()).isNotEqualTo(Boolean.TRUE); + assertThat(response.content()).isNotEmpty(); + + String message = ((TextContent) response.content().get(0)).text(); + assertThat(message).isEqualTo("Fast response"); + } + finally { + mcpServer.close(); + } + } + }