ChatClient calling tools that live on servers you didn’t write, don’t control, and in this article’s case didn’t even build in Java. A filesystem MCP server, a git MCP server — both official, both real, both started as ordinary child processes and driven over stdio.
The interesting part turns out not to be the “happy path” wiring, which is genuinely small. It’s what happens once you try to prove any of it without a live model in the loop: how do you deterministically trigger a tool call, capture a real timeout, or catch a logging bug that only shows up when a handler gets registered twice? Every fact below, including the two mistakes, came from actually running this — not from reading the reference docs and describing what they say.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1 (GA 12 June 2026, this maintenance release 21 August 2026), on Java 25 (LTS) — the same baseline as the MCP server article this one follows. The client dependency isspring-ai-starter-mcp-client, built on the official MCP Java SDK,io.modelcontextprotocol.sdk:mcp2.0.0. The two external servers are the official@modelcontextprotocol/server-filesystem2026.8.31 (npm, run vianpx) and the officialmcp-server-git1.30.0 (PyPI, run viauvx— there is no@modelcontextprotocol/server-gitnpm package; that name 404s on the registry).
What registering an external MCP server actually adds to ChatClient
Every toolChatClient has called so far in this series has been a @Tool-annotated method on a bean already living in the same JVM. An external MCP server’s tools are not that: each connection is a separate process — here, an npx or uvx invocation — that Spring AI starts, speaks JSON-RPC to over the process’s stdin and stdout, and keeps alive for the life of the application context. The model never knows the difference; from a prompt’s point of view a tool is a name, a description, and a JSON schema, whichever side of a process boundary it happens to live on.
spring.ai.mcp.client.stdio.connections, starts each as a subprocess, and exposes every connected client’s tools through one SyncMcpToolCallbackProvider bean:
spring:
ai:
mcp:
client:
request-timeout: 20s
stdio:
connections:
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "./fixtures/workspace"]
git:
command: uvx
args: ["mcp-server-git", "--repository", "./fixtures/demo-repo"]
Full file: application.yml. Wiring that provider into ChatClient is one method call, sitting right next to the method you’d use for a local tool:
@Bean
public ChatClient demoChatClient(ChatClient.Builder builder,
ToolCallbackProvider mcpToolCallbackProvider,
LocalClockTool localClockTool) {
return builder
.defaultToolCallbacks(mcpToolCallbackProvider)
.defaultTools(localClockTool)
.build();
}
Source: ChatClientConfig.java. defaultToolCallbacks(ToolCallbackProvider...) is the call that matters here — it exists specifically so an MCP client’s whole tool set can be registered in one line, without enumerating tool names by hand. Starting the application and listing every tool ChatClient now sees:
currentUtcTime Returns the current UTC time in ISO-8601 instant format. Runs in-process -- no external MCP server involved.
git_add Adds file contents to the staging area
git_branch List Git branches
git_log Shows the commit logs
git_status Shows the working tree status
...
total: 27 tools
Full, unabridged list: 01-registered-tools.txt. Fourteen tools from the filesystem server, twelve from git, one local — and notice the names: no server prefix on any of them, read_text_file and git_log sitting right next to currentUtcTime as if they’d always been local methods. That’s deliberate, and it’s not guaranteed to stay that way forever.
Going deeper: why there’s no prefix, and when one would appear
Spring AI’s default naming strategy, DefaultMcpToolNamePrefixGenerator, only prefixes a tool name when it collides with one already registered from a different connection (confirmed by disassembling the class with javap -c: it tracks every used name in a Set and only invents a prefix — via an incrementing counter — on the branch where adding the raw name to that set fails). The filesystem and git servers in this article happen to share no tool names, so nothing gets prefixed. Add a second filesystem-like server with its own read_text_file tool and the second one to register would suddenly gain a prefix the first one doesn’t have — worth knowing before you hardcode a tool name anywhere, including in a scripted test like the ones in this article, which resolve names dynamically for exactly this reason.
Going deeper on this section
- Companion repo: mcp-client module, all Maven coordinates in pom.xml
- Official reference: Spring AI – MCP Client Boot Starters
Calling a real tool without a real model in the loop
None of the tests in this article’s companion repository call a real LLM. That’s not a shortcut — it’s the only way to make a tool call deterministic and repeatable enough to capture in a transcript. The trick is that Spring AI 2.0 exposes the exact beanChatClient itself delegates to for tool execution, ToolCallingManager, as a normal Spring bean with one public method:
public interface ToolCallingManager {
List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions);
ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse);
}
Building an AssistantMessage.ToolCall by hand — exactly the shape a real model response would contain, just typed in instead of generated — and handing it to that bean drives real tool execution against real processes, with no model, no API key, and no network call to an LLM provider anywhere in the path:
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)));
Prompt prompt = new Prompt(priorMessages, ToolCallingChatOptions.builder().toolCallbacks(callbacks).build());
ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, chatResponse);
Source: McpToolCallingTest.java. Calling the local clock tool this way:
tool: currentUtcTime
arguments: {}
response: "2026-09-23T14:43:51.025702444Z"
Output: 02-local-tool-call.txt. Calling read_text_file on the real filesystem MCP server, against a real fixture file committed in the repository:
tool: read_text_file
arguments: {"path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/notes.txt"}
response: [{"text":"hello from the sandbox workspace\n"}]
Output: 03-filesystem-mcp-tool-call.txt. And git_log against a real, throwaway two-commit git repository built fresh at test time:
tool: git_log
arguments: {"repo_path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/demo-repo","max_count":5}
response: [{"text":"Commit history:\nCommit: 'b45115d1016791970e7827c8b2573a345c95f733'\nAuthor: <git.Actor \"Demo <[email protected]>\">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Add a second line\\n'\n\nCommit: 'aa0f59aeba079fc5a4e9f85c07d357e593e184b0'\nAuthor: <git.Actor \"Demo <[email protected]>\">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Initial commit\\n'\n"}]
Output: 04-git-mcp-tool-call.txt. That repository is built by a small GitFixture helper that runs real git init/git commit commands before the Spring context loads — committing an actual .git directory into this repository isn’t possible (git refuses to add a nested repository as plain files, only as a submodule gitlink), so the fixture is reconstructed, idempotently, every run instead.
Why this matters beyond testing. The same technique — building aChatResponsewith a scripted tool call and handing it toToolCallingManager— is how you’d replay a tool call from a support ticket, write a regression test for “the model should call X with Y arguments,” or benchmark a tool’s latency without burning model tokens on every run.ChatClientuses this exact bean internally; nothing about the path from here to a real model call is different once you swap in a realChatResponse.
Going deeper on this section
- Companion repo: GitFixture.java, the fixture builder and its Javadoc on why a nested
.gitcan’t be committed - Official server: @modelcontextprotocol/server-filesystem on npm
- Official server: mcp-server-git on PyPI
Local tools and MCP tools go through the same call path
defaultTools(Object...) and defaultToolCallbacks(ToolCallbackProvider...) look like two different registration mechanisms, and at registration time they are — one reflects over @Tool-annotated methods on a bean, the other asks an MCP client for its tool list. Once registered, both funnel through the identical DefaultToolCallingManager, which is why the scripted call in the previous section works exactly the same way for currentUtcTime as it does for read_text_file: same manager, same ToolCallback interface, same ToolResponseMessage shape coming back.
defaultTools(Object...) | defaultToolCallbacks(ToolCallbackProvider...) | |
|---|---|---|
| Registration | Reflects over @Tool-annotated methods | Asks the provider for its ToolCallback[] |
| Execution | Direct method invocation | McpSyncClient.callTool() — a real JSON-RPC round trip |
| Process | None — same JVM | One child process per connection, kept alive for the app’s lifetime |
| Failure mode | A thrown exception | isError:true inside a normal response — see below |
| Observed by | Same ToolCallingObservationContext | Same ToolCallingObservationContext |
Going deeper on this section
- Companion repo: LocalClockTool.java, the contrast case
Logging every tool call — and the way to accidentally log it twice
Spring AI 2.0’s idiomatic way to observe tool calls is the Micrometer Observation API:DefaultToolCallingManager wraps every call in a ToolCallingObservationContext carrying the tool’s definition, its arguments, its result, and whether it errored, and any registered ObservationHandler<ToolCallingObservationContext> gets a callback at start and stop. Registering one is a single @Bean:
@Bean
public ToolCallLoggingHandler toolCallLoggingHandler() {
return new ToolCallLoggingHandler();
}
Source: ObservabilityConfig.java. That one line is deliberately all that’s left. An earlier version of this exact class also wrapped the same handler in an ObservationRegistryCustomizer, on the theory that a customizer is the more “correct, explicit” way to touch an ObservationRegistry:
@Bean
public ObservationRegistryCustomizer<ObservationRegistry> toolCallObservationCustomizer(
ToolCallLoggingHandler handler) {
return registry -> registry.observationConfig().observationHandler(handler);
}
Running the demo with both beans present logged every tool call twice, at the same millisecond, byte-for-byte identical — not a race, not a retry, a clean duplicate. The reason is in Boot’s own autoconfiguration: ObservationAutoConfiguration.observationRegistryPostProcessor(...) takes both an ObjectProvider<ObservationHandler<?>> and an ObjectProvider<ObservationRegistryCustomizer<?>>, and applies both independently. A plain ObservationHandler bean is picked up automatically; wrapping that same instance in a customizer registers it again through the second path.
@Bean shown above, and a small counter in the handler now guards against a regression:
startCount before call: 4
startCount after call: 5
fired this call: 1
Output: 06-observation-fires-once.txt.
The fingerprint of this bug: tool-call log lines that come in exact pairs, same timestamp to the millisecond, same content. If you’ve registered an ObservationHandler bean and a customizer that adds it again, that’s what it looks like — not a retry, not concurrent logging, just the same event handled twice by a registry that was told about the handler through two independent paths.
Going deeper on this section
- Companion repo: ToolCallLoggingHandler.java and its regression test, ObservationRegistrationTest.java
What a failing MCP tool call actually looks like
The MCP server article in this series showed that a failing tool call comes back as HTTP200 with isError:true, never as an HTTP error status — that’s a server-side fact about what a compliant MCP server returns. The client-side question is what ToolCallingManager does with that response, and the answer is the same shape carried one layer further: it doesn’t throw.
tool: read_text_file
arguments: {"path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/does-not-exist.txt"}
response: Error calling tool: [TextContent[annotations=null, text=ENOENT: no such file or directory, open '/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/does-not-exist.txt', meta=null]]
Output: 05-mcp-tool-call-error.txt. executeToolCalls returns a completely normal ToolExecutionResult whose conversation history ends in a completely normal ToolResponseMessage — the difference between success and failure lives entirely inside that message’s text, not in whether the call completed. Code written to catch an exception around a tool call will never see this failure at all; code that hands the tool’s raw response back to a model, the way ChatClient itself does, lets the model read the error text and decide what to do next, which is the behaviour MCP is actually designed around.
If you’re writing code that inspects a tool result yourself — before handing it back to a model, or instead of using a model at all — check the text for failure, don’t wrap the call in a try/catch. Nothing in this path throws for a tool-level failure; only a transport-level failure (the process dying, a timeout, the JSON-RPC connection breaking) does, and that’s a different, rarer condition covered next.
Going deeper on this section
- Companion repo: McpToolCallingTest.java, the error-path test
- Related: Build an MCP Server with Spring AI 2.0 — the server-side half of this same fact
What a request timeout actually throws
spring.ai.mcp.client.request-timeout maps directly onto the MCP Java SDK’s McpClient.SyncSpec.requestTimeout(Duration) — confirmed by decompiling McpClientAutoConfiguration.mcpSyncClients(...), which also shows that the same autoconfiguration calls McpSyncClient.initialize() eagerly, as part of building the bean. The assumption that seems natural — that requestTimeout only bounds requests made after a successful handshake, and a separate, more generous initializationTimeout protects the handshake itself — is wrong. requestTimeout bounds every individual JSON-RPC round trip, including the initialize request itself. Setting initializationTimeout to a generous 30 seconds does not save a 1ms requestTimeout from failing the handshake:
requestTimeout=1ms, initializationTimeout=30s -- set deliberately far apart to see which
one actually governs the initialize() handshake
client.initialize() threw:
[0] java.lang.RuntimeException: Client failed to initialize by explicit API call
[0] suppressed: java.lang.Exception: #block terminated with an error
[1] java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 1ms in 'source(MonoDeferContextual)' (and no fallback has been configured)
confirmed: the real timeout-carrying exception is java.util.concurrent.TimeoutException, a plain JDK type from Reactor's
Flux.timeout() operator -- not a dedicated MCP timeout exception class.
Output: 07-request-timeout.txt. That last line matters beyond this one test: mcp-core-2.0.0.jar has no dedicated timeout exception type at all. Its real exception classes are McpTransportException, McpTransportSessionNotFoundException/...ClosedException, McpError, McpSchema.JSONRPCResponse.JSONRPCError, and McpHttpClientTransportAuthorizationException — checked directly against the jar’s contents, not assumed from the SDK’s public surface. A timeout surfaces as a bog-standard java.util.concurrent.TimeoutException, produced by the underlying Reactor pipeline’s timeout() operator, wrapped in whatever RuntimeException the calling code happened to throw around Mono.block(). Catch code written against a hypothetical McpTimeoutException will never match; catch TimeoutException by type, or match on the message, instead.
Going deeper: why the first version of this test got the wrong answer
The first attempt at this test started a full Spring application with spring.ai.mcp.client.request-timeout=1ms and expected context startup to fail — on the theory above, that the handshake would be governed by a separate timeout. It didn’t fail. Only after dropping to the raw MCP SDK client, setting requestTimeout and initializationTimeout independently, and watching initialize() actually throw did the real mechanism become clear: requestTimeout governs the handshake too. The full-context version of the test is gone from this repository; a wrong hypothesis that doesn’t survive contact with a real run doesn’t belong in a companion repo whose entire premise is that every claim was actually executed.
Going deeper on this section
- Companion repo: McpClientTimeoutTest.java
- Official reference: MCP Specification – Transports
Should you let a model call these tools at all?
Every tool this article registered has a real side effect available to it:write_file and edit_file can overwrite a file without confirmation; git_commit, git_add, and git_reset change real repository state. None of that is a flaw in either server — a filesystem tool that can’t write files and a git tool that can’t commit would be nearly useless — but it means registering an external MCP server’s full tool set with defaultToolCallbacks hands a model the ability to call every one of those tools, with whatever arguments it generates, the moment a conversation gives it a reason to. This article’s fixtures are throwaway and gitignored on purpose; nothing here points a model at anything that matters.
Should you even wire the whole tool set in? Registering an entire external server’s tools, unfiltered, is the fastest way to a working demo and the wrong default for anything a model can reach with real credentials. Spring AI’sThe MCP server article in this series covers securing the server side — OAuth2, scopes per tool, audit logging — and that protects a server you run from callers you don’t control. This article’s risk runs the other way: it’s about what you, as the client, choose to expose to a model from servers someone else wrote. Filtering the tool set, running write-capable servers only against disposable fixtures or sandboxes, and keeping a human in the loop for anything destructive are the practical answers until scoped, per-tool authorization on the client side is something Spring AI ships rather than something you build.McpToolFilter(an extra constructor argument onSyncMcpToolCallbackProvider, not exercised in this article) lets you register a named subset —read_text_fileandgit_logwithoutwrite_fileandgit_commit, say — and is worth reaching for before the first real deployment, not after the first incident.
Every Spring AI article on this site
| Article | Covers |
|---|---|
| Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1 | the beginner-level ChatClient build this article’s versions and setup follow |
| Build an MCP Server with Spring AI 2.0 | the server side of MCP — @McpTool, @McpResource, @McpPrompt, Streamable HTTP |
| Spring AI 1.x to 2.0: The Migration Guide | what breaks, and what breaks silently, upgrading an existing 1.x application |
| Production-Grade RAG with Spring AI | chunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector |
| Spring AI RAG in Java: Complete Code Tour | the same RAG project, file by file |
| Vector Embeddings and Semantic Search in Pure Java | the mechanics of embeddings and cosine similarity, without Spring AI — useful background before the RAG articles |
Further reading
- Companion repository for this article: asmhatre/spring-ai, mcp-client module
- Official reference: Spring AI – MCP Client Boot Starters
- Official reference: Model Context Protocol Specification
- Official server: @modelcontextprotocol/server-filesystem
- Official server: mcp-server-git
No Comments yet!