feat(mcp): Add request timeout configuration for MCP server

Add support for configuring request timeout in MCP server with a default of 20 seconds.
This timeout applies to all requests made through the client, including tool calls,
resource access, and prompt operations.

- Add requestTimeout property to McpServerProperties with default of 20s
- Configure server builder with the timeout value
- Add tests for default and custom timeout configurations
- Update documentation with the new property

Resolves #3205

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-05-17 09:49:06 +02:00
parent 8b8fb4f02d
commit 9ed7535ba0
4 changed files with 97 additions and 33 deletions

View File

@@ -256,6 +256,8 @@ public class McpServerAutoConfiguration {
serverBuilder.instructions(serverProperties.getInstructions());
serverBuilder.requestTimeout(serverProperties.getRequestTimeout());
return serverBuilder.build();
}
@@ -311,26 +313,26 @@ public class McpServerAutoConfiguration {
// Create the server with both tool and resource capabilities
AsyncSpecification serverBuilder = McpServer.async(transportProvider).serverInfo(serverInfo);
List<AsyncToolSpecification> toolSpecifications = new ArrayList<>(
tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
.filter(fc -> fc instanceof ToolCallback)
.map(fc -> (ToolCallback) fc)
.toList();
toolSpecifications.addAll(this.toAsyncToolSpecification(providerToolCallbacks, serverProperties));
// Tools
if (serverProperties.getCapabilities().isTool()) {
List<AsyncToolSpecification> toolSpecifications = new ArrayList<>(
tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
.filter(fc -> fc instanceof ToolCallback)
.map(fc -> (ToolCallback) fc)
.toList();
toolSpecifications.addAll(this.toAsyncToolSpecification(providerToolCallbacks, serverProperties));
logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification());
capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());
}
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
logger.info("Registered tools: " + toolSpecifications.size());
if (!CollectionUtils.isEmpty(toolSpecifications)) {
serverBuilder.tools(toolSpecifications);
logger.info("Registered tools: " + toolSpecifications.size());
}
}
// Resources
@@ -338,36 +340,38 @@ public class McpServerAutoConfiguration {
logger.info(
"Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification());
capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());
}
List<AsyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
logger.info("Registered resources: " + resourceSpecifications.size());
List<AsyncResourceSpecification> resourceSpecifications = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceSpecifications)) {
serverBuilder.resources(resourceSpecifications);
logger.info("Registered resources: " + resourceSpecifications.size());
}
}
// Prompts
if (serverProperties.getCapabilities().isPrompt()) {
logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification());
capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());
}
List<AsyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
logger.info("Registered prompts: " + promptSpecifications.size());
List<AsyncPromptSpecification> promptSpecifications = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptSpecifications)) {
serverBuilder.prompts(promptSpecifications);
logger.info("Registered prompts: " + promptSpecifications.size());
}
}
// Completions
if (serverProperties.getCapabilities().isCompletion()) {
logger.info("Enable completions capabilities");
capabilitiesBuilder.completions();
}
List<AsyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
logger.info("Registered completions: " + completionSpecifications.size());
List<AsyncCompletionSpecification> completionSpecifications = completions.stream()
.flatMap(List::stream)
.toList();
if (!CollectionUtils.isEmpty(completionSpecifications)) {
serverBuilder.completions(completionSpecifications);
logger.info("Registered completions: " + completionSpecifications.size());
}
}
rootsChangeConsumer.ifAvailable(consumer -> {
@@ -383,6 +387,8 @@ public class McpServerAutoConfiguration {
serverBuilder.instructions(serverProperties.getInstructions());
serverBuilder.requestTimeout(serverProperties.getRequestTimeout());
return serverBuilder.build();
}

View File

@@ -16,6 +16,7 @@
package org.springframework.ai.mcp.server.autoconfigure;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@@ -134,6 +135,22 @@ public class McpServerProperties {
private Capabilities capabilities = new Capabilities();
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
* resource access, and prompt operations.
*/
private Duration requestTimeout = Duration.ofSeconds(20);
public Duration getRequestTimeout() {
return this.requestTimeout;
}
public void setRequestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
}
public Capabilities getCapabilities() {
return this.capabilities;
}

View File

@@ -72,6 +72,10 @@ public class McpServerAutoConfigurationIT {
assertThat(properties.isToolChangeNotification()).isTrue();
assertThat(properties.isResourceChangeNotification()).isTrue();
assertThat(properties.isPromptChangeNotification()).isTrue();
assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(20);
assertThat(properties.getBaseUrl()).isEqualTo("");
assertThat(properties.getSseEndpoint()).isEqualTo("/sse");
assertThat(properties.getSseMessageEndpoint()).isEqualTo("/mcp/message");
// Check capabilities
assertThat(properties.getCapabilities().isTool()).isTrue();
@@ -85,7 +89,8 @@ public class McpServerAutoConfigurationIT {
void asyncConfiguration() {
this.contextRunner
.withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.name=test-server",
"spring.ai.mcp.server.version=2.0.0", "spring.ai.mcp.server.instructions=My MCP Server")
"spring.ai.mcp.server.version=2.0.0", "spring.ai.mcp.server.instructions=My MCP Server",
"spring.ai.mcp.server.request-timeout=30s")
.run(context -> {
assertThat(context).hasSingleBean(McpAsyncServer.class);
assertThat(context).doesNotHaveBean(McpSyncServer.class);
@@ -95,6 +100,7 @@ public class McpServerAutoConfigurationIT {
assertThat(properties.getVersion()).isEqualTo("2.0.0");
assertThat(properties.getInstructions()).isEqualTo("My MCP Server");
assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.ASYNC);
assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(30);
});
}
@@ -252,6 +258,10 @@ public class McpServerAutoConfigurationIT {
assertThat(properties.getCapabilities().isResource()).isFalse();
assertThat(properties.getCapabilities().isPrompt()).isFalse();
assertThat(properties.getCapabilities().isCompletion()).isFalse();
// Verify the server is configured with the disabled capabilities
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
}
@@ -273,6 +283,36 @@ public class McpServerAutoConfigurationIT {
});
}
@Test
void requestTimeoutConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.request-timeout=45s").run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(45);
// Verify the server is configured with the timeout
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
}
@Test
void endpointConfiguration() {
this.contextRunner
.withPropertyValues("spring.ai.mcp.server.base-url=http://localhost:8080",
"spring.ai.mcp.server.sse-endpoint=/events",
"spring.ai.mcp.server.sse-message-endpoint=/api/mcp/message")
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getBaseUrl()).isEqualTo("http://localhost:8080");
assertThat(properties.getSseEndpoint()).isEqualTo("/events");
assertThat(properties.getSseMessageEndpoint()).isEqualTo("/api/mcp/message");
// Verify the server is configured with the endpoints
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
}
@Test
void completionSpecificationConfiguration() {
this.contextRunner.withUserConfiguration(TestCompletionConfiguration.class).run(context -> {

View File

@@ -104,6 +104,7 @@ All properties are prefixed with `spring.ai.mcp.server`:
|`sse-message-endpoint` | Custom SSE Message endpoint path for web transport to be used by the client to send messages|`/mcp/message`
|`sse-endpoint` |Custom SSE endpoint path for web transport |`/sse`
|`base-url` | Optional URL prefix. For example `base-url=/api/v1` means that the client should access the sse endpont at `/api/v1` + `sse-endpoint` and the message endpoint is `/api/v1` + `sse-message-endpoint` | -
|`request-timeout` | Duration to wait for server responses before timing out requests. Applies to all requests made through the client, including tool calls, resource access, and prompt operations. | `20` seconds
|===
== Sync/Async Server Types