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

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