diff --git a/README.md b/README.md index 9037680..12e677a 100644 --- a/README.md +++ b/README.md @@ -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/) | | [`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-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/). diff --git a/mcp-client/.gitignore b/mcp-client/.gitignore new file mode 100644 index 0000000..34203aa --- /dev/null +++ b/mcp-client/.gitignore @@ -0,0 +1,3 @@ +target/ +# Built fresh by GitFixture at test time -- see its Javadoc. Never commit a nested repo. +fixtures/demo-repo/.git/ diff --git a/mcp-client/README.md b/mcp-client/README.md new file mode 100644 index 0000000..b097807 --- /dev/null +++ b/mcp-client/README.md @@ -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`. + +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 +``` diff --git a/mcp-client/fixtures/demo-repo/README.md b/mcp-client/fixtures/demo-repo/README.md new file mode 100644 index 0000000..7a15a50 --- /dev/null +++ b/mcp-client/fixtures/demo-repo/README.md @@ -0,0 +1,2 @@ +# Demo repo +line 2 diff --git a/mcp-client/fixtures/workspace/notes.txt b/mcp-client/fixtures/workspace/notes.txt new file mode 100644 index 0000000..c93ae48 --- /dev/null +++ b/mcp-client/fixtures/workspace/notes.txt @@ -0,0 +1 @@ +hello from the sandbox workspace diff --git a/mcp-client/output/01-registered-tools.txt b/mcp-client/output/01-registered-tools.txt new file mode 100644 index 0000000..cb0ea5e --- /dev/null +++ b/mcp-client/output/01-registered-tools.txt @@ -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 diff --git a/mcp-client/output/02-local-tool-call.txt b/mcp-client/output/02-local-tool-call.txt new file mode 100644 index 0000000..b399e5d --- /dev/null +++ b/mcp-client/output/02-local-tool-call.txt @@ -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" diff --git a/mcp-client/output/03-filesystem-mcp-tool-call.txt b/mcp-client/output/03-filesystem-mcp-tool-call.txt new file mode 100644 index 0000000..e69e71c --- /dev/null +++ b/mcp-client/output/03-filesystem-mcp-tool-call.txt @@ -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"}] diff --git a/mcp-client/output/04-git-mcp-tool-call.txt b/mcp-client/output/04-git-mcp-tool-call.txt new file mode 100644 index 0000000..5114453 --- /dev/null +++ b/mcp-client/output/04-git-mcp-tool-call.txt @@ -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: \">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Add a second line\\n'\n\nCommit: 'aa0f59aeba079fc5a4e9f85c07d357e593e184b0'\nAuthor: \">\nDate: 2026-09-23 14:43:44+00:00\nMessage: 'Initial commit\\n'\n"}] diff --git a/mcp-client/output/05-mcp-tool-call-error.txt b/mcp-client/output/05-mcp-tool-call-error.txt new file mode 100644 index 0000000..3b99677 --- /dev/null +++ b/mcp-client/output/05-mcp-tool-call-error.txt @@ -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. diff --git a/mcp-client/output/06-observation-fires-once.txt b/mcp-client/output/06-observation-fires-once.txt new file mode 100644 index 0000000..b517d54 --- /dev/null +++ b/mcp-client/output/06-observation-fires-once.txt @@ -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. diff --git a/mcp-client/output/07-request-timeout.txt b/mcp-client/output/07-request-timeout.txt new file mode 100644 index 0000000..4de50b6 --- /dev/null +++ b/mcp-client/output/07-request-timeout.txt @@ -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. diff --git a/mcp-client/pom.xml b/mcp-client/pom.xml new file mode 100644 index 0000000..ebddd5e --- /dev/null +++ b/mcp-client/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + mcp-client + 1.0.0 + mcp-client + 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. + + + 25 + + 2.0.1 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.ai + spring-ai-starter-mcp-client + + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/mcp-client/scripts/run-all.sh b/mcp-client/scripts/run-all.sh new file mode 100755 index 0000000..84631cc --- /dev/null +++ b/mcp-client/scripts/run-all.sh @@ -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." diff --git a/mcp-client/src/main/java/com/ankurm/mcpclient/McpClientApplication.java b/mcp-client/src/main/java/com/ankurm/mcpclient/McpClientApplication.java new file mode 100644 index 0000000..b407519 --- /dev/null +++ b/mcp-client/src/main/java/com/ankurm/mcpclient/McpClientApplication.java @@ -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); + } +} diff --git a/mcp-client/src/main/java/com/ankurm/mcpclient/config/ChatClientConfig.java b/mcp-client/src/main/java/com/ankurm/mcpclient/config/ChatClientConfig.java new file mode 100644 index 0000000..bc375f5 --- /dev/null +++ b/mcp-client/src/main/java/com/ankurm/mcpclient/config/ChatClientConfig.java @@ -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(); + } +} diff --git a/mcp-client/src/main/java/com/ankurm/mcpclient/config/ObservabilityConfig.java b/mcp-client/src/main/java/com/ankurm/mcpclient/config/ObservabilityConfig.java new file mode 100644 index 0000000..2c965e9 --- /dev/null +++ b/mcp-client/src/main/java/com/ankurm/mcpclient/config/ObservabilityConfig.java @@ -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>} and an + * {@code ObjectProvider>}) 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(); + } +} diff --git a/mcp-client/src/main/java/com/ankurm/mcpclient/observation/ToolCallLoggingHandler.java b/mcp-client/src/main/java/com/ankurm/mcpclient/observation/ToolCallLoggingHandler.java new file mode 100644 index 0000000..1a5998d --- /dev/null +++ b/mcp-client/src/main/java/com/ankurm/mcpclient/observation/ToolCallLoggingHandler.java @@ -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} 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 { + + 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; + } +} diff --git a/mcp-client/src/main/java/com/ankurm/mcpclient/tools/LocalClockTool.java b/mcp-client/src/main/java/com/ankurm/mcpclient/tools/LocalClockTool.java new file mode 100644 index 0000000..ea968d3 --- /dev/null +++ b/mcp-client/src/main/java/com/ankurm/mcpclient/tools/LocalClockTool.java @@ -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)); + } +} diff --git a/mcp-client/src/main/resources/application.yml b/mcp-client/src/main/resources/application.yml new file mode 100644 index 0000000..494f674 --- /dev/null +++ b/mcp-client/src/main/resources/application.yml @@ -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} diff --git a/mcp-client/src/test/java/com/ankurm/mcpclient/McpClientTimeoutTest.java b/mcp-client/src/test/java/com/ankurm/mcpclient/McpClientTimeoutTest.java new file mode 100644 index 0000000..947189a --- /dev/null +++ b/mcp-client/src/test/java/com/ankurm/mcpclient/McpClientTimeoutTest.java @@ -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. + * + *

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.

+ */ +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(); + } + } +} diff --git a/mcp-client/src/test/java/com/ankurm/mcpclient/McpToolCallingTest.java b/mcp-client/src/test/java/com/ankurm/mcpclient/McpToolCallingTest.java new file mode 100644 index 0000000..798674e --- /dev/null +++ b/mcp-client/src/test/java/com/ankurm/mcpclient/McpToolCallingTest.java @@ -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 allCallbacks() { + List callbacks = new ArrayList<>(List.of(mcpToolCallbackProvider.getToolCallbacks())); + callbacks.addAll(List.of( + MethodToolCallbackProvider.builder().toolObjects(localClockTool).build().getToolCallbacks())); + return callbacks; + } + + private ToolCallback findByNameContaining(List 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 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 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 callbacks = allCallbacks(); + List 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 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 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 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 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"); + } + } +} diff --git a/mcp-client/src/test/java/com/ankurm/mcpclient/ObservationRegistrationTest.java b/mcp-client/src/test/java/com/ankurm/mcpclient/ObservationRegistrationTest.java new file mode 100644 index 0000000..612dce1 --- /dev/null +++ b/mcp-client/src/test/java/com/ankurm/mcpclient/ObservationRegistrationTest.java @@ -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>} (which picks up the {@code @Bean} directly) + * and an {@code ObjectProvider>} (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 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 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); + } + } +} diff --git a/mcp-client/src/test/java/com/ankurm/mcpclient/support/GitFixture.java b/mcp-client/src/test/java/com/ankurm/mcpclient/support/GitFixture.java new file mode 100644 index 0000000..3d83bef --- /dev/null +++ b/mcp-client/src/test/java/com/ankurm/mcpclient/support/GitFixture.java @@ -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. + * + *

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.

+ */ +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", "demo@example.com"); + 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); + } + } +} diff --git a/mcp-client/src/test/java/com/ankurm/mcpclient/support/Transcript.java b/mcp-client/src/test/java/com/ankurm/mcpclient/support/Transcript.java new file mode 100644 index 0000000..4e673e1 --- /dev/null +++ b/mcp-client/src/test/java/com/ankurm/mcpclient/support/Transcript.java @@ -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); + } +}