Add langchain4j-interop: Spring AI 2.0 and LangChain4j 1.22 side by side

Same order assistant in both frameworks against a local fake OpenAI
server: starter bean collision, wire differences, tool failure and loop
bounds, observability meters, MCP-first tools, and a compile check of
the 2025 article's snippets and Maven coordinates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
Claude
2026-10-11 10:05:09 +00:00
parent d51ef56468
commit d3fec185aa
35 changed files with 1305 additions and 0 deletions
@@ -0,0 +1,111 @@
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();
}
}
}