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
66 lines
4.0 KiB
Markdown
66 lines
4.0 KiB
Markdown
# 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](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
|
|
```
|