package com.ankurm.interop; import static org.assertj.core.api.Assertions.assertThat; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.boot.WebApplicationType; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import dev.langchain4j.mcp.McpToolProvider; import dev.langchain4j.mcp.client.DefaultMcpClient; import dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport; import dev.langchain4j.service.AiServices; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; /** The order tool written once, behind an MCP server, and used from both frameworks as a client. */ class McpFirstTest { /** Nothing here imports either framework's tool annotation: only MCP's. */ public static class McpOrderTools { private final OrderService orders; public McpOrderTools(OrderService orders) { this.orders = orders; } @McpTool(name = "getOrderStatus", description = "Get current order status by order ID") public String getOrderStatus(@McpToolParam(description = "The unique identifier of the order", required = true) String orderId) { return orders.describe(orderId); } } @Configuration @EnableAutoConfiguration static class ServerApp { static final OrderService ORDERS = new OrderService(); @Bean McpOrderTools mcpOrderTools() { return new McpOrderTools(ORDERS); } } @Test void theToolBehindMcpServesBothFrameworks() throws Exception { try (FakeOpenAi fake = new FakeOpenAi(); ConfigurableApplicationContext server = new SpringApplicationBuilder(ServerApp.class) .web(WebApplicationType.SERVLET) .properties("server.port=0", "spring.ai.openai.api-key=unused", "spring.ai.mcp.client.enabled=false", "spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration", "spring.ai.mcp.server.name=orders", "spring.ai.mcp.server.version=1.0.0", "spring.ai.mcp.server.protocol=streamable") .run()) { int port = server.getEnvironment().getProperty("local.server.port", Integer.class); String base = "http://127.0.0.1:" + port; // Spring AI as an MCP client: the tools arrive as ToolCallbacks. var sdkClient = McpClient.sync(HttpClientStreamableHttpTransport.builder(base).endpoint("/mcp").build()) .requestTimeout(Duration.ofSeconds(10)).build(); sdkClient.initialize(); var springProvider = SyncMcpToolCallbackProvider.builder().mcpClients(sdkClient).build(); ChatClient spring = ChatClient.builder(Wiring.springModel(fake.url())).defaultSystem(Wiring.SYSTEM) .defaultToolCallbacks(springProvider).build(); String springAnswer = spring.prompt().user("Where is ORD-1002?").call().content(); int springRequests = fake.bodies.size(); String springTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)"); int callsAfterSpring = ServerApp.ORDERS.calls.get(); fake.reset(); // LangChain4j as an MCP client. var lcMcp = DefaultMcpClient.builder().key("orders") .transport(StreamableHttpMcpTransport.builder().url(base + "/mcp").build()).build(); Support lc = AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url())) .toolProvider(McpToolProvider.builder().mcpClients(List.of(lcMcp)).build()).build(); String lcAnswer = lc.chat("Where is ORD-1002?"); int lcRequests = fake.bodies.size(); String lcTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)"); int callsAfterBoth = ServerApp.ORDERS.calls.get(); try (var t = new Transcript("05-mcp-first.txt", "The order tool served once over MCP (Streamable HTTP), used from Spring AI and from LangChain4j")) { t.line("MCP server: Spring AI 2.0.1 spring-ai-starter-mcp-server-webmvc, one @McpTool method, on port %d.", port); t.line("Spring AI client : McpClient.sync(HttpClientStreamableHttpTransport) -> SyncMcpToolCallbackProvider -> ChatClient.defaultToolCallbacks"); t.line("LangChain4j client: DefaultMcpClient + StreamableHttpMcpTransport -> McpToolProvider -> AiServices.toolProvider"); t.blank(); t.line("Spring AI answer : %s (%d requests to the model)", springAnswer, springRequests); t.line(" tool offered to the model: %s", springTool); t.line("LangChain4j answer : %s (%d requests to the model)", lcAnswer, lcRequests); t.line(" tool offered to the model: %s", lcTool); t.blank(); t.line("Times the one tool method on the server ran: %d for Spring AI, then %d more for LangChain4j.", callsAfterSpring, callsAfterBoth - callsAfterSpring); } sdkClient.close(); lcMcp.close(); assertThat(springAnswer).contains("PROCESSING").isNotNull(); } } }