Files
Claude 60849f4319 Add mcp-client module: ChatClient calling tools from real external MCP servers over stdio
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
2026-09-23 14:44:10 +00:00

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();
}
}
}