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