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
..

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