package com.ankurm.mcpclient; import static org.assertj.core.api.Assertions.assertThat; import java.io.File; import java.time.Duration; import java.util.concurrent.TimeoutException; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Test; import com.ankurm.mcpclient.support.Transcript; /** * {@code spring.ai.mcp.client.request-timeout} maps straight onto * {@code McpClient.SyncSpec.requestTimeout(Duration)} -- confirmed by decompiling * {@code McpClientAutoConfiguration.mcpSyncClients(...)}, which also shows the eager * {@code McpSyncClient.initialize()} call that autoconfiguration makes happens through that * same spec. The first version of this test assumed {@code requestTimeout} would only bound * requests made *after* a successful handshake, and set {@code initializationTimeout} to a * generous 30s expecting it to protect {@code initialize()} on its own. It does not: * {@code requestTimeout} bounds every individual JSON-RPC round trip, including the * {@code initialize} request itself, so a 1ms {@code requestTimeout} fails the handshake * regardless of {@code initializationTimeout}. Caught by actually running it, not by reading * the spec's method names and guessing what each one scopes. * *

mcp-core-2.0.0.jar has no dedicated MCP timeout exception type (checked: the real * exception types there are McpTransportException, McpTransportSessionNotFoundException / * ...ClosedException, McpError, McpSchema.JSONRPCResponse.JSONRPCError, * McpHttpClientTransportAuthorizationException -- no *TimeoutException*). What actually comes * out, confirmed below, is a plain {@code java.util.concurrent.TimeoutException} from Reactor's * {@code Flux.timeout()} operator, wrapped in a {@code RuntimeException} that * {@code McpSyncClient.initialize()} throws when the underlying {@code Mono.block()} fails.

*/ class McpClientTimeoutTest { @Test void oneMillisecondRequestTimeoutFailsTheInitializeHandshake() { try (Transcript t = new Transcript("07-request-timeout.txt", "What a 1ms requestTimeout actually throws on a real MCP handshake -- captured, not guessed")) { String fsRoot = new File("fixtures/workspace").getAbsolutePath(); ServerParameters params = ServerParameters.builder("npx") .args("-y", "@modelcontextprotocol/server-filesystem", fsRoot) .build(); McpJsonMapper jsonMapper = new JacksonMcpJsonMapperSupplier().get(); StdioClientTransport transport = new StdioClientTransport(params, jsonMapper); McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofMillis(1)) .initializationTimeout(Duration.ofSeconds(30)) .clientInfo(new McpSchema.Implementation("timeout-demo", "1.0")) .build(); t.line("requestTimeout=1ms, initializationTimeout=30s -- set deliberately far apart to see which") .line("one actually governs the initialize() handshake") .blank(); Throwable failure = null; try { client.initialize(); t.line("client.initialize() unexpectedly succeeded within 1ms"); } catch (Throwable ex) { failure = ex; } finally { try { client.close(); } catch (Exception ignored) { // best-effort cleanup of the npx process; irrelevant to what this test verifies } } assertThat(failure) .as("initialize() with a 1ms requestTimeout should fail even with a generous initializationTimeout") .isNotNull(); t.line("client.initialize() threw:"); Throwable timeoutException = null; Throwable cause = failure; int depth = 0; while (cause != null && depth < 8) { t.line(" [%d] %s: %s", depth, cause.getClass().getName(), cause.getMessage()); if (cause instanceof TimeoutException) { timeoutException = cause; } for (Throwable suppressed : cause.getSuppressed()) { t.line(" [%d] suppressed: %s: %s", depth, suppressed.getClass().getName(), suppressed.getMessage()); Throwable suppressedCause = suppressed.getCause(); int sd = 0; while (suppressedCause != null && sd < 4) { t.line(" [%d] caused by: %s: %s", depth, suppressedCause.getClass().getName(), suppressedCause.getMessage()); if (suppressedCause instanceof TimeoutException) { timeoutException = suppressedCause; } suppressedCause = suppressedCause.getCause(); sd++; } } cause = cause.getCause(); depth++; } t.blank(); if (timeoutException != null) { t.line("confirmed: the real timeout-carrying exception is %s, a plain JDK type from Reactor's", timeoutException.getClass().getName()); t.line("Flux.timeout() operator -- not a dedicated MCP timeout exception class."); } assertThat(failure.getMessage()).contains("initialize"); assertThat(timeoutException).as("a java.util.concurrent.TimeoutException should be in the cause chain") .isNotNull(); } } }