# 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`. Companion code for [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) on [ankurm.com](https://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`](src/main/resources/application.yml) | `spring.ai.mcp.client.stdio.connections.{filesystem,git}` -- one stdio connection per server | | [`ChatClientConfig.java`](src/main/java/com/ankurm/mcpclient/config/ChatClientConfig.java) | `defaultToolCallbacks(mcpToolCallbackProvider)` for the two MCP servers, `defaultTools(localClockTool)` for the local tool | | [`LocalClockTool.java`](src/main/java/com/ankurm/mcpclient/tools/LocalClockTool.java) | The contrast case: one `@Tool` method, no process, no transport | | [`ToolCallLoggingHandler.java`](src/main/java/com/ankurm/mcpclient/observation/ToolCallLoggingHandler.java) | One handler, both kinds of tool -- MCP-sourced and local calls raise the same observation | | [`ObservabilityConfig.java`](src/main/java/com/ankurm/mcpclient/config/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 ```