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
This commit is contained in:
Claude
2026-09-23 14:44:10 +00:00
parent 9919da58a7
commit 60849f4319
25 changed files with 969 additions and 0 deletions
+1
View File
@@ -7,5 +7,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
| [`getting-started/`](getting-started) | One `ChatClient` bean, three endpoints (plain call, templated system prompt, streaming), and a test proving `spring.ai.model.chat` switches providers with zero code change. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1](https://ankurm.com/spring-ai-2-0-chatclient-boot-4-1/) | | [`getting-started/`](getting-started) | One `ChatClient` bean, three endpoints (plain call, templated system prompt, streaming), and a test proving `spring.ai.model.chat` switches providers with zero code change. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1](https://ankurm.com/spring-ai-2-0-chatclient-boot-4-1/) |
| [`rag/`](rag) | Ingest PDFs, chunk, retrieve from pgvector, rerank, answer, check the answer. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Production-grade RAG with Spring AI](https://ankurm.com/production-rag-spring-ai-java/) and [the complete example](https://ankurm.com/spring-ai-rag-complete-example/) | | [`rag/`](rag) | Ingest PDFs, chunk, retrieve from pgvector, rerank, answer, check the answer. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Production-grade RAG with Spring AI](https://ankurm.com/production-rag-spring-ai-java/) and [the complete example](https://ankurm.com/spring-ai-rag-complete-example/) |
| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) | | [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) |
| [`mcp-client/`](mcp-client) | `ChatClient` calling tools from two real external MCP servers (filesystem, git) over stdio via `defaultToolCallbacks(ToolCallbackProvider...)`, contrasted with a local `@Tool` method, with every call logged through one Micrometer `ObservationHandler`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) |
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/). Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
+3
View File
@@ -0,0 +1,3 @@
target/
# Built fresh by GitFixture at test time -- see its Javadoc. Never commit a nested repo.
fixtures/demo-repo/.git/
+65
View File
@@ -0,0 +1,65 @@
# 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
```
+2
View File
@@ -0,0 +1,2 @@
# Demo repo
line 2
+1
View File
@@ -0,0 +1 @@
hello from the sandbox workspace
+31
View File
@@ -0,0 +1,31 @@
# Tool names ChatClient sees -- 2 MCP servers (filesystem, git) plus 1 local @Tool method
create_directory Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.
currentUtcTime Returns the current UTC time in ISO-8601 instant format. Runs in-process -- no external MCP server involved.
directory_tree Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.
edit_file Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories.
get_file_info Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.
git_add Adds file contents to the staging area
git_branch List Git branches
git_checkout Switches branches
git_commit Records changes to the repository
git_create_branch Creates a new branch from an optional base branch
git_diff Shows differences between branches or commits
git_diff_staged Shows changes that are staged for commit
git_diff_unstaged Shows changes in the working directory that are not yet staged
git_log Shows the commit logs
git_reset Unstages all staged changes
git_show Shows the contents of a commit
git_status Shows the working tree status
list_allowed_directories Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.
list_directory Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
list_directory_with_sizes Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.
move_file Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.
read_file Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.
read_media_file Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories.
read_multiple_files Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.
read_text_file Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.
search_files Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.
write_file Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.
total: 27 tools
+6
View File
@@ -0,0 +1,6 @@
# Calling the local @Tool through the real ToolCallingManager -- no process, no MCP transport
tool: currentUtcTime
arguments: {}
response: "2026-09-23T14:43:51.025702444Z"
@@ -0,0 +1,6 @@
# Calling the filesystem MCP server's read_text_file tool through the real ToolCallingManager
tool: read_text_file
arguments: {"path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/notes.txt"}
response: [{"text":"hello from the sandbox workspace\n"}]
@@ -0,0 +1,6 @@
# Calling the git MCP server's git_log tool through the real ToolCallingManager
tool: git_log
arguments: {"repo_path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/demo-repo","max_count":5}
response: [{"text":"Commit history:\nCommit: 'b45115d1016791970e7827c8b2573a345c95f733'\nAuthor: <git.Actor \"Demo <[email protected]>\">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Add a second line\\n'\n\nCommit: 'aa0f59aeba079fc5a4e9f85c07d357e593e184b0'\nAuthor: <git.Actor \"Demo <[email protected]>\">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Initial commit\\n'\n"}]
@@ -0,0 +1,11 @@
# What a failing MCP tool call actually looks like through ToolCallingManager -- read_text_file against a path that does not exist
tool: read_text_file
arguments: {"path":"/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/does-not-exist.txt"}
response: Error calling tool: [TextContent[annotations=null, text=ENOENT: no such file or directory, open '/home/claude/work/spring-ai-clone/mcp-client/fixtures/workspace/does-not-exist.txt', meta=null]]
note: ToolCallingManager does not throw here. The MCP tools/call result came back
with isError:true (HTTP-equivalent 200-but-failed, same shape documented for the
server side in the mcp-server article); DefaultToolCallingManager turns that into a
normal ToolResponseMessage whose responseData is the error text, not an exception.
@@ -0,0 +1,10 @@
# Proving the tool-call ObservationHandler fires once per call, not twice
startCount before call: 4
startCount after call: 5
fired this call: 1
An earlier version of ObservabilityConfig wired this same handler two ways at once
(a plain @Bean, plus the same instance again inside an ObservationRegistryCustomizer).
Boot's ObservationAutoConfiguration picks up both independently, so every call logged
twice -- verified by running it that way first. See ObservabilityConfig's Javadoc.
+12
View File
@@ -0,0 +1,12 @@
# What a 1ms requestTimeout actually throws on a real MCP handshake -- captured, not guessed
requestTimeout=1ms, initializationTimeout=30s -- set deliberately far apart to see which
one actually governs the initialize() handshake
client.initialize() threw:
[0] java.lang.RuntimeException: Client failed to initialize by explicit API call
[0] suppressed: java.lang.Exception: #block terminated with an error
[1] java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 1ms in 'source(MonoDeferContextual)' (and no fallback has been configured)
confirmed: the real timeout-carrying exception is java.util.concurrent.TimeoutException, a plain JDK type from Reactor's
Flux.timeout() operator -- not a dedicated MCP timeout exception class.
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>mcp-client</artifactId>
<version>1.0.0</version>
<name>mcp-client</name>
<description>ChatClient calling tools from two real external MCP servers (filesystem, git) over stdio, contrasted with a local @Tool method, with every tool call observed via Micrometer.</description>
<properties>
<java.version>25</java.version>
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Stdio MCP client autoconfiguration: reads spring.ai.mcp.client.stdio.connections.*
and exposes McpSyncClient beans plus a SyncMcpToolCallbackProvider. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!-- Needed only so the module has a ChatModel/ChatClient.Builder to wire tool callbacks
into. No real model call is made in the tests; see ToolCallingManagerTest. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Regenerates every reproducible file under output/. Run from the module root
# (spring-ai/mcp-client). Needs npx and uvx on PATH -- both external MCP servers are launched
# fresh by the tests themselves.
set -eu
cd "$(dirname "$0")/.."
mvn -q -o test
echo
echo "Regenerated output/01 through 07 -- real MCP tool calls captured by the real"
echo "ToolCallingManager bean against real filesystem and git MCP servers, plus the local tool."
@@ -0,0 +1,17 @@
package com.ankurm.mcpclient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* See {@code https://ankurm.com/spring-ai-2-0-mcp-client/} -- ChatClient calling tools
* from two real external MCP servers (filesystem, git) over stdio, contrasted with a
* local {@code @Tool} method, with every tool call observed via Micrometer.
*/
@SpringBootApplication
public class McpClientApplication {
public static void main(String[] args) {
SpringApplication.run(McpClientApplication.class, args);
}
}
@@ -0,0 +1,35 @@
package com.ankurm.mcpclient.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.ankurm.mcpclient.tools.LocalClockTool;
/**
* Wires both kinds of tool into one ChatClient: the MCP-sourced tools (filesystem, git)
* via {@code defaultToolCallbacks(ToolCallbackProvider...)}, and the local, in-process
* tool via {@code defaultTools(Object...)}. {@code mcpToolCallbackProvider} is the
* {@code SyncMcpToolCallbackProvider} bean that spring-ai-starter-mcp-client autoconfigures
* from every {@code McpSyncClient} bean built from
* {@code spring.ai.mcp.client.stdio.connections.*}.
*/
@Configuration
public class ChatClientConfig {
@Bean
public LocalClockTool localClockTool() {
return new LocalClockTool();
}
@Bean
public ChatClient demoChatClient(ChatClient.Builder builder,
ToolCallbackProvider mcpToolCallbackProvider,
LocalClockTool localClockTool) {
return builder
.defaultToolCallbacks(mcpToolCallbackProvider)
.defaultTools(localClockTool)
.build();
}
}
@@ -0,0 +1,27 @@
package com.ankurm.mcpclient.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.ankurm.mcpclient.observation.ToolCallLoggingHandler;
/**
* Just a plain {@code @Bean} of type {@code ObservationHandler<...>}. Boot's
* {@code ObservationAutoConfiguration} (see
* {@code ObservationAutoConfiguration.observationRegistryPostProcessor}, which takes both
* an {@code ObjectProvider<ObservationHandler<?>>} and an
* {@code ObjectProvider<ObservationRegistryCustomizer<?>>}) finds any {@code ObservationHandler}
* bean and registers it automatically. Also wrapping the same handler in an
* {@code ObservationRegistryCustomizer} -- which looked like the more "explicit" way to do
* this -- registers it a second time: every tool call then logs twice, at the same
* millisecond, with identical content. Verified by running it wired both ways; see the
* "going deeper" note in the article.
*/
@Configuration
public class ObservabilityConfig {
@Bean
public ToolCallLoggingHandler toolCallLoggingHandler() {
return new ToolCallLoggingHandler();
}
}
@@ -0,0 +1,65 @@
package com.ankurm.mcpclient.observation;
import java.util.concurrent.atomic.AtomicInteger;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.ai.tool.observation.ToolCallingObservationContext;
/**
* The idiomatic "log every tool call" mechanism in Spring AI 2.0: an
* {@code ObservationHandler<ToolCallingObservationContext>} registered on the
* {@code ObservationRegistry}, not a hand-rolled wrapper around {@code ToolCallback}.
* Because {@code DefaultToolCallingManager} raises this same observation for MCP-sourced
* and local {@code @Tool} calls alike, one handler covers both -- see
* {@link com.ankurm.mcpclient.config.ObservabilityConfig}.
*/
public class ToolCallLoggingHandler implements ObservationHandler<ToolCallingObservationContext> {
private static final Log logger = LogFactory.getLog(ToolCallLoggingHandler.class);
/** Counts onStart invocations -- exists so tests can prove this handler fires once per
* call, not twice. See the class Javadoc: it is easy to end up wired twice. */
private final AtomicInteger startCount = new AtomicInteger();
public int startCount() {
return startCount.get();
}
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof ToolCallingObservationContext;
}
@Override
public void onStart(ToolCallingObservationContext context) {
startCount.incrementAndGet();
logger.info("tool-call start name=" + context.getToolDefinition().name()
+ " type=" + context.getToolType()
+ " id=" + context.getToolCallId()
+ " args=" + context.getToolCallArguments());
}
@Override
public void onStop(ToolCallingObservationContext context) {
logger.info("tool-call stop name=" + context.getToolDefinition().name()
+ " id=" + context.getToolCallId()
+ " result=" + trim(context.getToolCallResult()));
}
@Override
public void onError(ToolCallingObservationContext context) {
logger.warn("tool-call error name=" + context.getToolDefinition().name()
+ " id=" + context.getToolCallId()
+ " error=" + context.getError());
}
private static String trim(String result) {
if (result == null) {
return "null";
}
return result.length() > 200 ? result.substring(0, 200) + "...(truncated)" : result;
}
}
@@ -0,0 +1,23 @@
package com.ankurm.mcpclient.tools;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import org.springframework.ai.tool.annotation.Tool;
/**
* A local, in-process tool -- the contrast case for the MCP-sourced tools registered via
* {@code ChatClient.Builder.defaultToolCallbacks(ToolCallbackProvider)}. This one is wired
* with {@code ChatClient.Builder.defaultTools(Object...)} instead: no process, no transport,
* no JSON-RPC round trip. Both kinds of tool go through the same
* {@code DefaultToolCallingManager} and emit the same {@code ToolCallingObservationContext}
* events -- see {@link com.ankurm.mcpclient.observation.ToolCallLoggingHandler}.
*/
public class LocalClockTool {
@Tool(description = "Returns the current UTC time in ISO-8601 instant format. Runs in-process -- no external MCP server involved.")
public String currentUtcTime() {
return DateTimeFormatter.ISO_INSTANT.format(Instant.now().atZone(ZoneOffset.UTC));
}
}
@@ -0,0 +1,31 @@
spring:
application:
name: mcp-client
ai:
# No real model call is made anywhere in this module's tests (see ToolCallingManagerTest) --
# this starter exists only to give the demo a ChatClient.Builder to wire tool callbacks into.
openai:
api-key: ${OPENAI_API_KEY:not-a-real-key-see-readme}
mcp:
client:
enabled: true
name: mcp-client-demo
version: 1.0.0
request-timeout: 20s
type: SYNC
toolcallback:
enabled: true
stdio:
connections:
filesystem:
command: ${MCP_CLIENT_NPX_CMD:npx}
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- ${MCP_CLIENT_FS_ROOT:./fixtures/workspace}
git:
command: ${MCP_CLIENT_UVX_CMD:uvx}
args:
- "mcp-server-git"
- "--repository"
- ${MCP_CLIENT_GIT_REPO:./fixtures/demo-repo}
@@ -0,0 +1,121 @@
package com.ankurm.mcpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.time.Duration;
import java.util.concurrent.TimeoutException;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Test;
import com.ankurm.mcpclient.support.Transcript;
/**
* {@code spring.ai.mcp.client.request-timeout} maps straight onto
* {@code McpClient.SyncSpec.requestTimeout(Duration)} -- confirmed by decompiling
* {@code McpClientAutoConfiguration.mcpSyncClients(...)}, which also shows the eager
* {@code McpSyncClient.initialize()} call that autoconfiguration makes happens through that
* same spec. The first version of this test assumed {@code requestTimeout} would only bound
* requests made *after* a successful handshake, and set {@code initializationTimeout} to a
* generous 30s expecting it to protect {@code initialize()} on its own. It does not:
* {@code requestTimeout} bounds every individual JSON-RPC round trip, including the
* {@code initialize} request itself, so a 1ms {@code requestTimeout} fails the handshake
* regardless of {@code initializationTimeout}. Caught by actually running it, not by reading
* the spec's method names and guessing what each one scopes.
*
* <p>mcp-core-2.0.0.jar has no dedicated MCP timeout exception type (checked: the real
* exception types there are McpTransportException, McpTransportSessionNotFoundException /
* ...ClosedException, McpError, McpSchema.JSONRPCResponse.JSONRPCError,
* McpHttpClientTransportAuthorizationException -- no *TimeoutException*). What actually comes
* out, confirmed below, is a plain {@code java.util.concurrent.TimeoutException} from Reactor's
* {@code Flux.timeout()} operator, wrapped in a {@code RuntimeException} that
* {@code McpSyncClient.initialize()} throws when the underlying {@code Mono.block()} fails.</p>
*/
class McpClientTimeoutTest {
@Test
void oneMillisecondRequestTimeoutFailsTheInitializeHandshake() {
try (Transcript t = new Transcript("07-request-timeout.txt",
"What a 1ms requestTimeout actually throws on a real MCP handshake -- captured, not guessed")) {
String fsRoot = new File("fixtures/workspace").getAbsolutePath();
ServerParameters params = ServerParameters.builder("npx")
.args("-y", "@modelcontextprotocol/server-filesystem", fsRoot)
.build();
McpJsonMapper jsonMapper = new JacksonMcpJsonMapperSupplier().get();
StdioClientTransport transport = new StdioClientTransport(params, jsonMapper);
McpSyncClient client = McpClient.sync(transport)
.requestTimeout(Duration.ofMillis(1))
.initializationTimeout(Duration.ofSeconds(30))
.clientInfo(new McpSchema.Implementation("timeout-demo", "1.0"))
.build();
t.line("requestTimeout=1ms, initializationTimeout=30s -- set deliberately far apart to see which")
.line("one actually governs the initialize() handshake")
.blank();
Throwable failure = null;
try {
client.initialize();
t.line("client.initialize() unexpectedly succeeded within 1ms");
} catch (Throwable ex) {
failure = ex;
} finally {
try {
client.close();
} catch (Exception ignored) {
// best-effort cleanup of the npx process; irrelevant to what this test verifies
}
}
assertThat(failure)
.as("initialize() with a 1ms requestTimeout should fail even with a generous initializationTimeout")
.isNotNull();
t.line("client.initialize() threw:");
Throwable timeoutException = null;
Throwable cause = failure;
int depth = 0;
while (cause != null && depth < 8) {
t.line(" [%d] %s: %s", depth, cause.getClass().getName(), cause.getMessage());
if (cause instanceof TimeoutException) {
timeoutException = cause;
}
for (Throwable suppressed : cause.getSuppressed()) {
t.line(" [%d] suppressed: %s: %s", depth, suppressed.getClass().getName(),
suppressed.getMessage());
Throwable suppressedCause = suppressed.getCause();
int sd = 0;
while (suppressedCause != null && sd < 4) {
t.line(" [%d] caused by: %s: %s", depth, suppressedCause.getClass().getName(),
suppressedCause.getMessage());
if (suppressedCause instanceof TimeoutException) {
timeoutException = suppressedCause;
}
suppressedCause = suppressedCause.getCause();
sd++;
}
}
cause = cause.getCause();
depth++;
}
t.blank();
if (timeoutException != null) {
t.line("confirmed: the real timeout-carrying exception is %s, a plain JDK type from Reactor's",
timeoutException.getClass().getName());
t.line("Flux.timeout() operator -- not a dedicated MCP timeout exception class.");
}
assertThat(failure.getMessage()).contains("initialize");
assertThat(timeoutException).as("a java.util.concurrent.TimeoutException should be in the cause chain")
.isNotNull();
}
}
}
@@ -0,0 +1,201 @@
package com.ankurm.mcpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.ankurm.mcpclient.support.GitFixture;
import com.ankurm.mcpclient.support.Transcript;
import com.ankurm.mcpclient.tools.LocalClockTool;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* No real LLM is involved anywhere in this class. Every {@code AssistantMessage.ToolCall} is
* built by hand, exactly as a real model response would contain one, and handed to the real
* {@code ToolCallingManager} bean -- the same bean {@code ChatClient} uses internally. That
* bean resolves the call against a real {@code McpSyncClient} talking, over real stdio, to a
* real {@code npx @modelcontextprotocol/server-filesystem} process or a real
* {@code uvx mcp-server-git} process (see {@code src/main/resources/application.yml} and
* {@code fixtures/}), or against the local, in-process {@link LocalClockTool}. This is what
* "verified against a real run" means for tool calling: no mocks anywhere in this file.
*/
@SpringBootTest
class McpToolCallingTest {
static {
GitFixture.ensureDemoRepo(new File("fixtures/demo-repo").toPath());
}
@Autowired
ToolCallingManager toolCallingManager;
@Autowired
ToolCallbackProvider mcpToolCallbackProvider;
@Autowired
LocalClockTool localClockTool;
private final ObjectMapper objectMapper = new ObjectMapper();
private List<ToolCallback> allCallbacks() {
List<ToolCallback> callbacks = new ArrayList<>(List.of(mcpToolCallbackProvider.getToolCallbacks()));
callbacks.addAll(List.of(
MethodToolCallbackProvider.builder().toolObjects(localClockTool).build().getToolCallbacks()));
return callbacks;
}
private ToolCallback findByNameContaining(List<ToolCallback> callbacks, String fragment) {
return callbacks.stream()
.filter(cb -> cb.getToolDefinition().name().contains(fragment))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no registered tool name contains '" + fragment
+ "' -- registered names: "
+ callbacks.stream().map(cb -> cb.getToolDefinition().name()).toList()));
}
private ToolExecutionResult invoke(List<ToolCallback> callbacks, String toolName, String argumentsJson) {
ToolCallingChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(callbacks)
.build();
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"call_" + UUID.randomUUID().toString().substring(0, 8), "function", toolName, argumentsJson);
AssistantMessage assistantMessage = AssistantMessage.builder()
.content("")
.toolCalls(List.of(toolCall))
.build();
ChatResponse chatResponse = new ChatResponse(List.of(new Generation(assistantMessage)));
List<Message> priorMessages = List.of(new UserMessage("(scripted -- see McpToolCallingTest)"));
Prompt prompt = new Prompt(priorMessages, options);
return toolCallingManager.executeToolCalls(prompt, chatResponse);
}
private String lastToolResponse(ToolExecutionResult result) {
Message last = result.conversationHistory().get(result.conversationHistory().size() - 1);
ToolResponseMessage toolResponseMessage = (ToolResponseMessage) last;
return toolResponseMessage.getResponses().get(0).responseData();
}
@Test
void listsToolsFromBothMcpServersAndTheLocalTool() {
try (Transcript t = new Transcript("01-registered-tools.txt",
"Tool names ChatClient sees -- 2 MCP servers (filesystem, git) plus 1 local @Tool method")) {
List<ToolCallback> callbacks = allCallbacks();
List<ToolDefinition> defs = callbacks.stream()
.map(ToolCallback::getToolDefinition)
.sorted(Comparator.comparing(ToolDefinition::name))
.toList();
for (ToolDefinition def : defs) {
t.line("%-60s %s", def.name(), def.description());
}
t.blank().line("total: %d tools", defs.size());
assertThat(defs).hasSizeGreaterThanOrEqualTo(14 + 12 + 1);
assertThat(defs.stream().anyMatch(d -> d.name().equals("currentUtcTime"))).isTrue();
}
}
@Test
void callsLocalToolThroughToolCallingManager() {
try (Transcript t = new Transcript("02-local-tool-call.txt",
"Calling the local @Tool through the real ToolCallingManager -- no process, no MCP transport")) {
List<ToolCallback> callbacks = allCallbacks();
ToolExecutionResult result = invoke(callbacks, "currentUtcTime", "{}");
String response = lastToolResponse(result);
t.line("tool: currentUtcTime").line("arguments: {}").blank()
.line("response: %s", response);
assertThat(response).matches("\"?\\d{4}-\\d{2}-\\d{2}T.*");
}
}
@Test
void callsFilesystemMcpToolThroughToolCallingManager() {
try (Transcript t = new Transcript("03-filesystem-mcp-tool-call.txt",
"Calling the filesystem MCP server's read_text_file tool through the real ToolCallingManager")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback readTool = findByNameContaining(callbacks, "read_text_file");
String absolutePath = new File("fixtures/workspace/notes.txt").getAbsolutePath();
String args = "{\"path\":\"" + absolutePath.replace("\\", "\\\\") + "\"}";
ToolExecutionResult result = invoke(callbacks, readTool.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", readTool.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response);
assertThat(response).contains("hello from the sandbox workspace");
}
}
@Test
void callsGitMcpToolThroughToolCallingManager() {
try (Transcript t = new Transcript("04-git-mcp-tool-call.txt",
"Calling the git MCP server's git_log tool through the real ToolCallingManager")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback gitLog = findByNameContaining(callbacks, "git_log");
String repoPath = new File("fixtures/demo-repo").getAbsolutePath();
String args = "{\"repo_path\":\"" + repoPath.replace("\\", "\\\\") + "\",\"max_count\":5}";
ToolExecutionResult result = invoke(callbacks, gitLog.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", gitLog.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response);
assertThat(response).contains("Add a second line").contains("Initial commit");
}
}
@Test
void capturesRealErrorFromAFailingMcpToolCall() {
try (Transcript t = new Transcript("05-mcp-tool-call-error.txt",
"What a failing MCP tool call actually looks like through ToolCallingManager -- "
+ "read_text_file against a path that does not exist")) {
List<ToolCallback> callbacks = allCallbacks();
ToolCallback readTool = findByNameContaining(callbacks, "read_text_file");
String missingPath = new File("fixtures/workspace/does-not-exist.txt").getAbsolutePath();
String args = "{\"path\":\"" + missingPath.replace("\\", "\\\\") + "\"}";
ToolExecutionResult result = invoke(callbacks, readTool.getToolDefinition().name(), args);
String response = lastToolResponse(result);
t.line("tool: %s", readTool.getToolDefinition().name())
.line("arguments: %s", args)
.blank()
.line("response: %s", response)
.blank()
.line("note: ToolCallingManager does not throw here. The MCP tools/call result came back")
.line("with isError:true (HTTP-equivalent 200-but-failed, same shape documented for the")
.line("server side in the mcp-server article); DefaultToolCallingManager turns that into a")
.line("normal ToolResponseMessage whose responseData is the error text, not an exception.");
assertThat(response.toLowerCase()).containsAnyOf("error", "no such file", "enoent", "not found");
}
}
}
@@ -0,0 +1,90 @@
package com.ankurm.mcpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.File;
import com.ankurm.mcpclient.observation.ToolCallLoggingHandler;
import com.ankurm.mcpclient.support.GitFixture;
import com.ankurm.mcpclient.support.Transcript;
import com.ankurm.mcpclient.tools.LocalClockTool;
/**
* {@code ObservabilityConfig} registers {@link ToolCallLoggingHandler} as one plain
* {@code @Bean}. The first version of this module also wrapped that same bean in an
* {@code ObservationRegistryCustomizer} -- which read as the more "correct, explicit" way to
* register a Micrometer handler. Running the demo showed every tool-call log line twice, at
* the same millisecond, byte-for-byte identical. Boot's own
* {@code ObservationAutoConfiguration.observationRegistryPostProcessor} takes both an
* {@code ObjectProvider<ObservationHandler<?>>} (which picks up the {@code @Bean} directly)
* and an {@code ObjectProvider<ObservationRegistryCustomizer<?>>} (which ran the customizer
* too) -- so the handler ended up registered on the {@code ObservationRegistry} twice. This
* test is the regression guard for the fix: exactly one {@code @Bean}, nothing else.
*/
@SpringBootTest
class ObservationRegistrationTest {
static {
GitFixture.ensureDemoRepo(new File("fixtures/demo-repo").toPath());
}
@Autowired
ToolCallingManager toolCallingManager;
@Autowired
ToolCallLoggingHandler toolCallLoggingHandler;
@Autowired
LocalClockTool localClockTool;
@Test
void toolCallLoggingHandlerFiresExactlyOncePerCall() {
try (Transcript t = new Transcript("06-observation-fires-once.txt",
"Proving the tool-call ObservationHandler fires once per call, not twice")) {
int before = toolCallLoggingHandler.startCount();
List<ToolCallback> callbacks = List.of(
MethodToolCallbackProvider.builder().toolObjects(localClockTool).build().getToolCallbacks());
ToolCallingChatOptions options = ToolCallingChatOptions.builder().toolCallbacks(callbacks).build();
AssistantMessage.ToolCall toolCall =
new AssistantMessage.ToolCall("call_observation_test", "function", "currentUtcTime", "{}");
AssistantMessage assistantMessage =
AssistantMessage.builder().content("").toolCalls(List.of(toolCall)).build();
ChatResponse chatResponse = new ChatResponse(List.of(new Generation(assistantMessage)));
List<Message> priorMessages = List.of(new UserMessage("(scripted -- see ObservationRegistrationTest)"));
Prompt prompt = new Prompt(priorMessages, options);
toolCallingManager.executeToolCalls(prompt, chatResponse);
int after = toolCallLoggingHandler.startCount();
int firedThisCall = after - before;
t.line("startCount before call: %d", before)
.line("startCount after call: %d", after)
.line("fired this call: %d", firedThisCall)
.blank()
.line("An earlier version of ObservabilityConfig wired this same handler two ways at once")
.line("(a plain @Bean, plus the same instance again inside an ObservationRegistryCustomizer).")
.line("Boot's ObservationAutoConfiguration picks up both independently, so every call logged")
.line("twice -- verified by running it that way first. See ObservabilityConfig's Javadoc.");
assertThat(firedThisCall).isEqualTo(1);
}
}
}
@@ -0,0 +1,67 @@
package com.ankurm.mcpclient.support;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
/**
* Builds the real, throwaway git repository at {@code fixtures/demo-repo} that the git MCP
* server (see {@code application.yml}) reports on. A {@code .git} directory is deliberately
* not committed to this repository -- git does not let a nested repository be added as plain
* files, only as a submodule gitlink, which would leave the fixture's actual history missing
* from every clone. Building it once, idempotently, at test time keeps the fixture real (an
* actual `git init`/`git commit` sequence runs) without embedding one repository inside
* another.
*
* <p>Called from a static initializer, not {@code @BeforeAll}, in every {@code @SpringBootTest}
* class in this module: {@code McpClientAutoConfiguration} initializes the git {@code McpSyncClient}
* eagerly, as part of context refresh, so the fixture must exist before Spring even starts
* building the context -- a static initializer runs at class-load time, which is always
* earlier.</p>
*/
public final class GitFixture {
private GitFixture() {
}
public static synchronized void ensureDemoRepo(Path repoDir) {
try {
if (Files.isDirectory(repoDir.resolve(".git"))) {
return;
}
Files.createDirectories(repoDir);
Path readme = repoDir.resolve("README.md");
Files.writeString(readme, "# Demo repo\n");
run(repoDir, "git", "init", "-q", "-b", "main");
run(repoDir, "git", "config", "user.email", "[email protected]");
run(repoDir, "git", "config", "user.name", "Demo");
run(repoDir, "git", "add", "-A");
run(repoDir, "git", "commit", "-q", "-m", "Initial commit");
Files.writeString(readme, "line 2\n", StandardOpenOption.APPEND);
run(repoDir, "git", "commit", "-aqm", "Add a second line");
} catch (IOException e) {
throw new UncheckedIOException("could not build git fixture at " + repoDir, e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted building git fixture at " + repoDir, e);
}
}
private static void run(Path cwd, String... command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(List.of(command))
.directory(cwd.toFile())
.redirectErrorStream(true)
.start();
String out = new String(process.getInputStream().readAllBytes());
int exit = process.waitFor();
if (exit != 0) {
throw new IllegalStateException(
"command " + List.of(command) + " in " + cwd + " exited " + exit + ": " + out);
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.mcpclient.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}