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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user