defaultToolCallbacks(ToolCallbackProvider) wires two real stdio MCP servers (the official filesystem server and git server) into ChatClient, contrasted with defaultTools(Object) for a local @Tool method. Every call -- MCP-sourced or local -- is logged through one Micrometer ObservationHandler<ToolCallingObservationContext>; a first version wired that handler two ways at once and every call logged twice, which is now a regression test. No real LLM is used anywhere: every test builds an AssistantMessage.ToolCall by hand and drives it through the real ToolCallingManager bean against real npx/uvx-launched MCP server processes. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
122 lines
6.0 KiB
Java
122 lines
6.0 KiB
Java
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.
|
|
*
|
|
* <p>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.</p>
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
}
|