Skip to main content

Spring AI MCP Client: Calling External MCP Servers from ChatClient

Connect ChatClient to two real external MCP servers over stdio — the official filesystem and git servers — with defaultToolCallbacks, contrasted with a local @Tool method. Real tool calls driven through ToolCallingManager with no LLM involved, a double-logging trap in the Observation API, what isError:true really does client-side, and what a too-short request-timeout actually throws.

The last article in this series built an MCP server — your Spring service, exposed as tools someone else’s client can call. This one is the other direction: your 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 is spring-ai-starter-mcp-client, built on the official MCP Java SDK, io.modelcontextprotocol.sdk:mcp 2.0.0. The two external servers are the official @modelcontextprotocol/server-filesystem 2026.8.31 (npm, run via npx) and the official mcp-server-git 1.30.0 (PyPI, run via uvx — there is no @modelcontextprotocol/server-git npm package; that name 404s on the registry).

What registering an external MCP server actually adds to ChatClient

Every tool ChatClient 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.
ChatClient one list of tool callbacks defaultToolCallbacks(provider) SyncMcpToolCallbackProvider defaultTools(localClockTool) plain @Tool method, one bean npx server-filesystem real child process, stdio uvx mcp-server-git real child process, stdio LocalClockTool in-process, no transport 27 tools total in this article’s build: 14 filesystem + 12 git + 1 local. Same ChatClient, same call path.
Spring AI’s stdio client autoconfiguration reads one connection per entry under 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

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 bean ChatClient 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 a ChatResponse with a scripted tool call and handing it to ToolCallingManager — 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. ChatClient uses this exact bean internally; nothing about the path from here to a real model call is different once you swap in a real ChatResponse.

Going deeper on this section

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...)
RegistrationReflects over @Tool-annotated methodsAsks the provider for its ToolCallback[]
ExecutionDirect method invocationMcpSyncClient.callTool() — a real JSON-RPC round trip
ProcessNone — same JVMOne child process per connection, kept alive for the app’s lifetime
Failure modeA thrown exceptionisError:true inside a normal response — see below
Observed bySame ToolCallingObservationContextSame ToolCallingObservationContext
The practical upshot: whatever you build to log, meter, or gate tool calls does not need a special case for “this one happens to be MCP.” One handler, shown next, covers both.

Going deeper on this section

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 ToolCallLoggingHandler picked up as ObservationHandler<?> @Bean ObservationRegistryCustomizer wraps the same handler instance ObservationRegistry handler registered twice Fix: one @Bean, nothing else. A regression test now asserts the handler fires once per call.
The fix is the single @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

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 HTTP 200 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

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

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’s McpToolFilter (an extra constructor argument on SyncMcpToolCallbackProvider, not exercised in this article) lets you register a named subset — read_text_file and git_log without write_file and git_commit, say — and is worth reaching for before the first real deployment, not after the first incident.
The 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.

Every Spring AI article on this site

ArticleCovers
Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1the beginner-level ChatClient build this article’s versions and setup follow
Build an MCP Server with Spring AI 2.0the server side of MCP — @McpTool, @McpResource, @McpPrompt, Streamable HTTP
Spring AI 1.x to 2.0: The Migration Guidewhat breaks, and what breaks silently, upgrading an existing 1.x application
Production-Grade RAG with Spring AIchunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector
Spring AI RAG in Java: Complete Code Tourthe same RAG project, file by file
Vector Embeddings and Semantic Search in Pure Javathe mechanics of embeddings and cosine similarity, without Spring AI — useful background before the RAG articles

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.