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
4.0 KiB
mcp-client
ChatClient calling tools from two real external MCP servers over stdio -- the official
@modelcontextprotocol/server-filesystem (npm) and the official mcp-server-git (PyPI, run via
uvx) -- registered with defaultToolCallbacks(ToolCallbackProvider...), alongside a local,
in-process @Tool method registered the older way, with defaultTools(Object...), for contrast.
Every tool call, from either source, is logged through one Micrometer
ObservationHandler<ToolCallingObservationContext>.
Companion code for Spring AI MCP Client: Calling External MCP Servers from ChatClient on ankurm.com.
Versions
Spring Boot 4.1.1, Spring AI 2.0.1, Java 25 (LTS) -- same baseline as the rest of this
repository. @modelcontextprotocol/server-filesystem 2026.8.31 (npm), mcp-server-git
1.30.0 (PyPI, resolved at run time by uvx -- no persistent install).
Requirements to run the tests
npx and uvx on PATH. Both servers are launched fresh per test run; nothing is installed
into this repository or your global npm/uv caches beyond their normal package caches.
What's wired where
| File | What it does |
|---|---|
application.yml |
spring.ai.mcp.client.stdio.connections.{filesystem,git} -- one stdio connection per server |
ChatClientConfig.java |
defaultToolCallbacks(mcpToolCallbackProvider) for the two MCP servers, defaultTools(localClockTool) for the local tool |
LocalClockTool.java |
The contrast case: one @Tool method, no process, no transport |
ToolCallLoggingHandler.java |
One handler, both kinds of tool -- MCP-sourced and local calls raise the same observation |
ObservabilityConfig.java |
Registers the handler -- see its Javadoc for the double-registration trap this module hit first |
Fixtures
fixtures/workspace/notes.txt is what read_text_file reads. fixtures/demo-repo is a real,
throwaway git repository with two real commits (Initial commit, Add a second line) that
git_log reports on. Neither is generated at test time; both are committed so the transcripts
in output/ are reproducible without depending on state built during a previous run.
Tests and captured output
No real LLM is called anywhere in this module. Every test builds an
AssistantMessage.ToolCall by hand -- exactly the shape a real model response would contain --
and hands it to the real ToolCallingManager bean, the same bean ChatClient uses internally.
That bean resolves the call against a real McpSyncClient talking, over real stdio, to a real
npx @modelcontextprotocol/server-filesystem or uvx mcp-server-git process, or against the
local LocalClockTool bean directly.
| Output file | What it captures |
|---|---|
output/01-registered-tools.txt |
All 27 tool names ChatClient sees -- 14 from the filesystem server, 12 from git, 1 local |
output/02-local-tool-call.txt |
Calling the local @Tool through ToolCallingManager |
output/03-filesystem-mcp-tool-call.txt |
Calling read_text_file on the filesystem MCP server |
output/04-git-mcp-tool-call.txt |
Calling git_log on the git MCP server |
output/05-mcp-tool-call-error.txt |
A real failing MCP tool call -- isError:true, not a thrown exception |
output/06-observation-fires-once.txt |
Regression guard for the double-logging trap: proves the handler fires once per call |
output/07-request-timeout.txt |
What requestTimeout=1ms actually throws on a real handshake -- a plain java.util.concurrent.TimeoutException, not a dedicated MCP timeout type |
Run mvn -o test (or ./scripts/run-all.sh) to regenerate all seven.
Regenerating output
./scripts/run-all.sh