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
This commit is contained in:
Claude
2026-09-23 14:44:10 +00:00
parent 9919da58a7
commit 60849f4319
25 changed files with 969 additions and 0 deletions
@@ -0,0 +1,121 @@
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();
}
}
}
@@ -0,0 +1,201 @@
package com.ankurm.mcpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.ankurm.mcpclient.support.GitFixture;
import com.ankurm.mcpclient.support.Transcript;
import com.ankurm.mcpclient.tools.LocalClockTool;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* No real LLM is involved anywhere in this class. Every {@code AssistantMessage.ToolCall} is
* built by hand, exactly as a real model response would contain one, and handed to the real
* {@code ToolCallingManager} bean -- the same bean {@code ChatClient} uses internally. That
* bean resolves the call against a real {@code McpSyncClient} talking, over real stdio, to a
* real {@code npx @modelcontextprotocol/server-filesystem} process or a real
* {@code uvx mcp-server-git} process (see {@code src/main/resources/application.yml} and
* {@code fixtures/}), or against the local, in-process {@link LocalClockTool}. This is what
* "verified against a real run" means for tool calling: no mocks anywhere in this file.
*/
@SpringBootTest
class McpToolCallingTest {
static {
GitFixture.ensureDemoRepo(new File("fixtures/demo-repo").toPath());
}
@Autowired
ToolCallingManager toolCallingManager;
@Autowired
ToolCallbackProvider mcpToolCallbackProvider;
@Autowired
LocalClockTool localClockTool;
private final ObjectMapper objectMapper = new ObjectMapper();
private List<ToolCallback> allCallbacks() {
List<ToolCallback> callbacks = new ArrayList<>(List.of(mcpToolCallbackProvider.getToolCallbacks()));
callbacks.addAll(List.of(
MethodToolCallbackProvider.builder().toolObjects(localClockTool).build().getToolCallbacks()));
return callbacks;
}
private ToolCallback findByNameContaining(List<ToolCallback> callbacks, String fragment) {
return callbacks.stream()
.filter(cb -> cb.getToolDefinition().name().contains(fragment))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no registered tool name contains '" + fragment
+ "' -- registered names: "
+ callbacks.stream().map(cb -> cb.getToolDefinition().name()).toList()));
}
private ToolExecutionResult invoke(List<ToolCallback> callbacks, String toolName, String argumentsJson) {
ToolCallingChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(callbacks)
.build();
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"call_" + UUID.randomUUID().toString().substring(0, 8), "function", toolName, argumentsJson);
AssistantMessage assistantMessage = AssistantMessage.builder()
.content("")
.toolCalls(List.of(toolCall))
.build();
ChatResponse chatResponse = new ChatResponse(List.of(new Generation(assistantMessage)));
List<Message> priorMessages = List.of(new UserMessage("(scripted -- see McpToolCallingTest)"));
Prompt prompt = new Prompt(priorMessages, options);
return toolCallingManager.executeToolCalls(prompt, chatResponse);
}
private String lastToolResponse(ToolExecutionResult result) {
Message last = result.conversationHistory().get(result.conversationHistory().size() - 1);
ToolResponseMessage toolResponseMessage = (ToolResponseMessage) last;
return toolResponseMessage.getResponses().get(0).responseData();
}
@Test
void listsToolsFromBothMcpServersAndTheLocalTool() {
try (Transcript t = new Transcript("01-registered-tools.txt",
"Tool names ChatClient sees -- 2 MCP servers (filesystem, git) plus 1 local @Tool method")) {
List<ToolCallback> callbacks = allCallbacks();
List<ToolDefinition> defs = callbacks.stream()
.map(ToolCallback::getToolDefinition)
.sorted(Comparator.comparing(ToolDefinition::name))
.toList();
for (ToolDefinition def : defs) {
t.line("%-60s %s", def.name(), def.description());
}
t.blank().line("total: %d tools", defs.size());
assertThat(defs).hasSizeGreaterThanOrEqualTo(14 + 12 + 1);
assertThat(defs.stream().anyMatch(d -> d.name().equals("currentUtcTime"))).isTrue();
}
}
@Test
void callsLocalToolThroughToolCallingManager() {
try (Transcript t = new Transcript("02-local-tool-call.txt",
"Calling the local @Tool through the real ToolCallingManager -- no process, no MCP transport")) {
List<ToolCallback> callbacks = allCallbacks();
ToolExecutionResult result = invoke(callbacks, "currentUtcTime", "{}");
String response = lastToolResponse(result);
t.line("tool: currentUtcTime").line("arguments: {}").blank()
.line("response: %s", response);
assertThat(response).matches("\"?\\d{4}-\\d{2}-\\d{2}T.*");
}
}
@Test
void callsFilesystemMcpToolThroughToolCallingManager() {
try (Transcript t = new Transcript("03-filesystem-mcp-tool-call.txt",
"Calling the filesystem MCP server's read_text_file tool through the real ToolCallingManager")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback readTool = findByNameContaining(callbacks, "read_text_file");
String absolutePath = new File("fixtures/workspace/notes.txt").getAbsolutePath();
String args = "{\"path\":\"" + absolutePath.replace("\\", "\\\\") + "\"}";
ToolExecutionResult result = invoke(callbacks, readTool.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", readTool.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response);
assertThat(response).contains("hello from the sandbox workspace");
}
}
@Test
void callsGitMcpToolThroughToolCallingManager() {
try (Transcript t = new Transcript("04-git-mcp-tool-call.txt",
"Calling the git MCP server's git_log tool through the real ToolCallingManager")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback gitLog = findByNameContaining(callbacks, "git_log");
String repoPath = new File("fixtures/demo-repo").getAbsolutePath();
String args = "{\"repo_path\":\"" + repoPath.replace("\\", "\\\\") + "\",\"max_count\":5}";
ToolExecutionResult result = invoke(callbacks, gitLog.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", gitLog.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response);
assertThat(response).contains("Add a second line").contains("Initial commit");
}
}
@Test
void capturesRealErrorFromAFailingMcpToolCall() {
try (Transcript t = new Transcript("05-mcp-tool-call-error.txt",
"What a failing MCP tool call actually looks like through ToolCallingManager -- "
+ "read_text_file against a path that does not exist")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback readTool = findByNameContaining(callbacks, "read_text_file");
String missingPath = new File("fixtures/workspace/does-not-exist.txt").getAbsolutePath();
String args = "{\"path\":\"" + missingPath.replace("\\", "\\\\") + "\"}";
ToolExecutionResult result = invoke(callbacks, readTool.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", readTool.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response)
.blank()
.line("note: ToolCallingManager does not throw here. The MCP tools/call result came back")
.line("with isError:true (HTTP-equivalent 200-but-failed, same shape documented for the")
.line("server side in the mcp-server article); DefaultToolCallingManager turns that into a")
.line("normal ToolResponseMessage whose responseData is the error text, not an exception.");
assertThat(response.toLowerCase()).containsAnyOf("error", "no such file", "enoent", "not found");
}
}
}
@@ -0,0 +1,90 @@
package com.ankurm.mcpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.File;
import com.ankurm.mcpclient.observation.ToolCallLoggingHandler;
import com.ankurm.mcpclient.support.GitFixture;
import com.ankurm.mcpclient.support.Transcript;
import com.ankurm.mcpclient.tools.LocalClockTool;
/**
* {@code ObservabilityConfig} registers {@link ToolCallLoggingHandler} as one plain
* {@code @Bean}. The first version of this module also wrapped that same bean in an
* {@code ObservationRegistryCustomizer} -- which read as the more "correct, explicit" way to
* register a Micrometer handler. Running the demo showed every tool-call log line twice, at
* the same millisecond, byte-for-byte identical. Boot's own
* {@code ObservationAutoConfiguration.observationRegistryPostProcessor} takes both an
* {@code ObjectProvider<ObservationHandler<?>>} (which picks up the {@code @Bean} directly)
* and an {@code ObjectProvider<ObservationRegistryCustomizer<?>>} (which ran the customizer
* too) -- so the handler ended up registered on the {@code ObservationRegistry} twice. This
* test is the regression guard for the fix: exactly one {@code @Bean}, nothing else.
*/
@SpringBootTest
class ObservationRegistrationTest {
static {
GitFixture.ensureDemoRepo(new File("fixtures/demo-repo").toPath());
}
@Autowired
ToolCallingManager toolCallingManager;
@Autowired
ToolCallLoggingHandler toolCallLoggingHandler;
@Autowired
LocalClockTool localClockTool;
@Test
void toolCallLoggingHandlerFiresExactlyOncePerCall() {
try (Transcript t = new Transcript("06-observation-fires-once.txt",
"Proving the tool-call ObservationHandler fires once per call, not twice")) {
int before = toolCallLoggingHandler.startCount();
List<ToolCallback> callbacks = List.of(
MethodToolCallbackProvider.builder().toolObjects(localClockTool).build().getToolCallbacks());
ToolCallingChatOptions options = ToolCallingChatOptions.builder().toolCallbacks(callbacks).build();
AssistantMessage.ToolCall toolCall =
new AssistantMessage.ToolCall("call_observation_test", "function", "currentUtcTime", "{}");
AssistantMessage assistantMessage =
AssistantMessage.builder().content("").toolCalls(List.of(toolCall)).build();
ChatResponse chatResponse = new ChatResponse(List.of(new Generation(assistantMessage)));
List<Message> priorMessages = List.of(new UserMessage("(scripted -- see ObservationRegistrationTest)"));
Prompt prompt = new Prompt(priorMessages, options);
toolCallingManager.executeToolCalls(prompt, chatResponse);
int after = toolCallLoggingHandler.startCount();
int firedThisCall = after - before;
t.line("startCount before call: %d", before)
.line("startCount after call: %d", after)
.line("fired this call: %d", firedThisCall)
.blank()
.line("An earlier version of ObservabilityConfig wired this same handler two ways at once")
.line("(a plain @Bean, plus the same instance again inside an ObservationRegistryCustomizer).")
.line("Boot's ObservationAutoConfiguration picks up both independently, so every call logged")
.line("twice -- verified by running it that way first. See ObservabilityConfig's Javadoc.");
assertThat(firedThisCall).isEqualTo(1);
}
}
}
@@ -0,0 +1,67 @@
package com.ankurm.mcpclient.support;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
/**
* Builds the real, throwaway git repository at {@code fixtures/demo-repo} that the git MCP
* server (see {@code application.yml}) reports on. A {@code .git} directory is deliberately
* not committed to this repository -- git does not let a nested repository be added as plain
* files, only as a submodule gitlink, which would leave the fixture's actual history missing
* from every clone. Building it once, idempotently, at test time keeps the fixture real (an
* actual `git init`/`git commit` sequence runs) without embedding one repository inside
* another.
*
* <p>Called from a static initializer, not {@code @BeforeAll}, in every {@code @SpringBootTest}
* class in this module: {@code McpClientAutoConfiguration} initializes the git {@code McpSyncClient}
* eagerly, as part of context refresh, so the fixture must exist before Spring even starts
* building the context -- a static initializer runs at class-load time, which is always
* earlier.</p>
*/
public final class GitFixture {
private GitFixture() {
}
public static synchronized void ensureDemoRepo(Path repoDir) {
try {
if (Files.isDirectory(repoDir.resolve(".git"))) {
return;
}
Files.createDirectories(repoDir);
Path readme = repoDir.resolve("README.md");
Files.writeString(readme, "# Demo repo\n");
run(repoDir, "git", "init", "-q", "-b", "main");
run(repoDir, "git", "config", "user.email", "[email protected]");
run(repoDir, "git", "config", "user.name", "Demo");
run(repoDir, "git", "add", "-A");
run(repoDir, "git", "commit", "-q", "-m", "Initial commit");
Files.writeString(readme, "line 2\n", StandardOpenOption.APPEND);
run(repoDir, "git", "commit", "-aqm", "Add a second line");
} catch (IOException e) {
throw new UncheckedIOException("could not build git fixture at " + repoDir, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted building git fixture at " + repoDir, e);
}
}
private static void run(Path cwd, String... command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(List.of(command))
.directory(cwd.toFile())
.redirectErrorStream(true)
.start();
String out = new String(process.getInputStream().readAllBytes());
int exit = process.waitFor();
if (exit != 0) {
throw new IllegalStateException(
"command " + List.of(command) + " in " + cwd + " exited " + exit + ": " + out);
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.mcpclient.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}