前言
这次的定语比较多,又是 简易 又是 client 的
如果你去翻阅 langchain4j 有关 MCP 的文档(点击这里),你会发现有关它的内容比起 RAG 少的可怜
在我看来,应该是以下几个原因有关
- langchain4j 并没有实现完整的 MCP 协议。在目前版本(1.0.1)的 langchain4j 中是不存在 MCP Server 这个组件的
- langchain4j 对于 MCP Client 的理解和 Function Calling 异曲同工,有一些逻辑在其他模块已经实现了
我其实是有些纳闷的,因为我最初就是想用 langchain4j 把现有的服务构建成一个 MCP Server ,至于 MCP Client 我想许多客户端都可以充当这个角色
以上推论主观性很强,如果有错误,可以评论或者私信
langchain4j 中的 MCP Client
在 langchain4j 中,McpClient 本质还是一个 tool
从官方文档中给的例子中可以看出来一些端倪
java
McpTransport transport = new HttpMcpTransport.Builder()
.sseUrl("http://localhost:3001/sse")
.logRequests(true) // if you want to see the traffic in the log
.logResponses(true)
.build();
McpClient mcpClient = new DefaultMcpClient.Builder()
.key("MyMCPClient")
.transport(transport)
.build();
McpToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(mcpClient)
.build();
Bot bot = AiServices.builder(Bot.class)
.chatModel(model)
.toolProvider(toolProvider)
.build();
先是构建了一个基于 SSE 的传输对象 transport
,然后封装成一个 McpClient
客户端,再包装成一个工具供应商 McpToolProvider
,最后交给 AiServices
构建一个服务组件
AiServices
除了有 toolProvider
属性,还有 tools
属性。而这个 tools
属性就是 Function Calling 用来配置 tool
的
看一下源码(dev.langchain4j.service.tool.ToolService#createContext
)
java
public ToolServiceContext createContext(Object memoryId, UserMessage userMessage) {
if (this.toolProvider == null) {
return this.toolSpecifications.isEmpty() ?
new ToolServiceContext(null, null) :
new ToolServiceContext(this.toolSpecifications, this.toolExecutors);
}
List<ToolSpecification> toolsSpecs = new ArrayList<>(this.toolSpecifications);
Map<String, ToolExecutor> toolExecs = new HashMap<>(this.toolExecutors);
ToolProviderRequest toolProviderRequest = new ToolProviderRequest(memoryId, userMessage);
ToolProviderResult toolProviderResult = toolProvider.provideTools(toolProviderRequest);
if (toolProviderResult != null) {
for (Map.Entry<ToolSpecification, ToolExecutor> entry :
toolProviderResult.tools().entrySet()) {
if (toolExecs.putIfAbsent(entry.getKey().name(), entry.getValue()) == null) {
toolsSpecs.add(entry.getKey());
} else {
throw new IllegalConfigurationException(
"Duplicated definition for tool: " + entry.getKey().name());
}
}
}
return new ToolServiceContext(toolsSpecs, toolExecs);
}
逻辑大概是
- 如果
toolProvider
为空,则只包装了tools
来源的工具
toolSpecifications 和 toolExecutors 是 tools 属性处理得到的
- 否则,先将
toolSpecifications
和toolExecutors
添加到容器中做准备 - 重点在于
provideTools
方法,它将McpClient
的tool
列举出来(通过MCP
协议的实现dev.langchain4j.mcp.client.McpClient#listTools
)并返回了ToolProviderResult
dev.langchain4j.mcp.McpToolProvider#provideTools(dev.langchain4j.service.tool.ToolProviderRequest, java.util.function.BiPredicate<dev.langchain4j.mcp.client.McpClient,dev.langchain4j.agent.tool.ToolSpecification>)
源码如下
java
protected ToolProviderResult provideTools(ToolProviderRequest request, BiPredicate<McpClient, ToolSpecification> mcpToolsFilter) {
ToolProviderResult.Builder builder = ToolProviderResult.builder();
for (McpClient mcpClient : mcpClients) {
try {
mcpClient.listTools().stream().filter(tool -> mcpToolsFilter.test(mcpClient, tool))
.forEach(toolSpecification -> {
builder.add(toolSpecification, (executionRequest, memoryId) -> mcpClient.executeTool(executionRequest));
});
} catch (IllegalConfigurationException e) {
throw e;
} catch (Exception e) {
if (failIfOneServerFails) {
throw new RuntimeException("Failed to retrieve tools from MCP server", e);
} else {
log.warn("Failed to retrieve tools from MCP server", e);
}
}
}
return builder.build();
}
这里就可以看出来,其实本质上和普通的 Function Calling
没有什么区别
- 将
ToolProviderResult
中的tool
添加到第二步准备好的容器内
剩下就是和 Function Calling
一样,把 tool
的描述交给大模型,让大模型判断应该使用哪些工具来协助完成用户的提问
项目演示
目标
利用 langchain4j 的 MCP Client
能力,使用 高德地图 和 腾讯地图 的 MCP Server
来实现一个旅行规划
准备
大模型换用其它的国内模型也没问题,我习惯用 deepseek 了
其实百度地图也提供了它们的 MCP Server,不过它们的 key 申请需要认证开发者
编码
pom 依赖
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>top.wuhunyu.mcp</groupId>
<artifactId>langchain4j-mcp-client-example</artifactId>
<version>0.0.1</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.4.0</spring-boot.version>
<langchain4j.version>1.0.1</langchain4j.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- springboot 版本锁定 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<!-- langchain4j 版本锁定 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<!-- langchain4j springboot 启动器 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>
<!-- langchain4j 响应式编程 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
</dependency>
<!-- langchain4j openai 接入 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
</dependency>
<!-- langchain4j mcp 接入 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mcp</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
application.yml 配置
yaml
spring:
application:
name: langchain4j-mcp-client-example
langchain4j:
# 大模型
open-ai:
chat-model:
base-url: https://api.deepseek.com/v1
api-key: [替换成你的 deepseek api key]
model-name: deepseek-chat
log-requests: true
log-responses: true
# 流式大模型
streaming-chat-model:
base-url: ${langchain4j.open-ai.chat-model.base-url}
api-key: ${langchain4j.open-ai.chat-model.api-key}
model-name: deepseek-chat
log-requests: ${langchain4j.open-ai.chat-model.log-requests}
log-responses: ${langchain4j.open-ai.chat-model.log-responses}
mcp:
# 高德地图
amap:
sse-url: https://mcp.amap.com/sse?key=[替换成你的 高德地图 api key]
client-name: amap-client
client-version: 0.0.1
# 腾讯地图
qq:
sse-url: https://mcp.map.qq.com/sse?key=[替换成你的 腾讯地图 api key]
client-name: qq-client
client-version: 0.0.1
组件配置
java
public interface MapClientService {
@SystemMessage("你是一个旅行规划专家,你可以根据用户的需求,为用户规划出旅行路线。")
Result<String> plan(@MemoryId Long userId, @UserMessage String userMessage);
}
java
// 手动构建 AiService
@Bean("mapService")
public MapClientService mapService(
ChatModel chatModel,
StreamingChatModel streamingChatModel,
ChatMemoryProvider chatMemoryProvider,
RetrievalAugmentor retrievalAugmentor,
@Qualifier("amapMcpClient") McpClient amapMcpClient,
@Qualifier("qqMcpClient") McpClient qqMcpClient,
DateTool dateTool
) {
final var mcpToolProvider = new McpToolProvider.Builder()
.failIfOneServerFails(false)
.mcpClients(List.of(
amapMcpClient,
qqMcpClient
))
.build();
return AiServices.builder(MapClientService.class)
.chatModel(chatModel)
.streamingChatModel(streamingChatModel)
.chatMemoryProvider(chatMemoryProvider)
.retrievalAugmentor(retrievalAugmentor)
.toolProvider(mcpToolProvider)
.tools(dateTool)
.build();
}
只贴出了最关键的部分
注意除了 toolProvider
配置 MCP Client
以外,还添加了一个 tools
。这个日期工具只提供了一个基本的当前日期时间功能
java
@Component("dateTool")
public class DateTool {
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Tool("查询当前的日期时间,以 yyyy-MM-dd HH:mm:ss 格式返回")
public String now() {
return LocalDateTime.now().format(dateTimeFormatter);
}
}
执行结果
让我们调用 MapClientService#plan
看看效果。我现在想要知道这个周末在深圳的旅游规划,提示词如下
markdown
请帮我规划一下这周周末深圳的两天旅行规划,要求精确到小时的安排
这是最后的效果
markdown
以下是深圳周末两天(2025年6月14日-15日)的详细旅行计划,具体到每小时的活动安排:
---
### **第一天:6月14日(星期六)**
#### **上午**
- **08:00-09:00**:早餐
推荐地点:深圳本地早茶店(如点都德、陶陶居)
活动:品尝广式早茶,如虾饺、叉烧包等。
- **09:00-11:00**:深圳湾公园
活动:散步、骑行,欣赏深圳湾海景和红树林生态。
- **11:00-12:00**:前往华侨城创意文化园
交通:打车或地铁(约20分钟)
活动:参观艺术展览、创意小店。
#### **中午**
- **12:00-13:30**:午餐
推荐地点:华侨城创意园内的特色餐厅(如"旧天堂书店"或"Gaga鲜语")
活动:享用午餐,稍作休息。
#### **下午**
- **13:30-15:30**:世界之窗
交通:打车或地铁(约15分钟)
活动:参观世界著名景点的微缩模型,拍照打卡。
- **15:30-17:00**:欢乐海岸
活动:购物、散步,欣赏水秀表演(如时间合适)。
- **17:00-18:00**:前往深圳湾万象城
交通:步行或打车(约10分钟)
活动:购物或休闲。
#### **晚上**
- **18:00-19:30**:晚餐
推荐地点:深圳湾万象城的高端餐厅(如"炳胜品味")
活动:享用晚餐。
- **19:30-21:00**:深圳湾灯光秀(如开放)
活动:欣赏深圳湾夜景和灯光表演。
- **21:00-22:00**:返回酒店
交通:打车或地铁
活动:休息。
---
### **第二天:6月15日(星期日)**
#### **上午**
- **08:00-09:00**:早餐
推荐地点:酒店早餐或附近咖啡馆。
- **09:00-11:00**:大梅沙海滨公园
交通:打车或自驾(约40分钟)
活动:沙滩漫步、游泳(如天气允许)。
- **11:00-12:00**:东部华侨城
活动:参观茶溪谷或大峡谷,体验自然风光。
#### **中午**
- **12:00-13:30**:午餐
推荐地点:东部华侨城内的餐厅
活动:享用午餐。
#### **下午**
- **13:30-15:30**:中英街
交通:打车(约30分钟)
活动:购物、了解历史。
- **15:30-17:00**:盐田海滨栈道
活动:散步,欣赏海景。
- **17:00-18:00**:返回市区
交通:打车或地铁
活动:休息。
#### **晚上**
- **18:00-19:30**:晚餐
推荐地点:市区内的海鲜餐厅(如"79号渔船")
活动:享用晚餐。
- **19:30-21:00**:深圳平安金融中心云际观景台
活动:俯瞰深圳夜景。
- **21:00-22:00**:返回酒店或前往机场/车站
交通:打车或地铁
活动:结束行程。
---
### **注意事项**
1. **交通**:深圳地铁覆盖广泛,建议使用地铁或打车。
2. **天气**:提前查看天气,携带防晒或雨具。
3. **门票**:部分景点(如世界之窗、东部华侨城)需提前购票。
如果需要调整或补充,请告诉我!
其他问题
发现了一处 bug:okhttp 的响应流未关闭,控制台打印了警告日志
shell
2025-06-10 18:08:01.957 [OkHttp ConnectionPool] WARN okhttp3.OkHttpClient - A connection to https://mcp.map.qq.com/ was leaked. Did you forget to close a response body? To see where this was allocated, set the OkHttpClient logger level to FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);
2025-06-10 18:08:01.957 [OkHttp ConnectionPool] WARN okhttp3.OkHttpClient - A connection to https://mcp.map.qq.com/ was leaked. Did you forget to close a response body? To see where this was allocated, set the OkHttpClient logger level to FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);
问题源码(dev.langchain4j.mcp.client.transport.http.HttpMcpTransport#execute
)如下
java
private CompletableFuture<JsonNode> execute(Request request, Long id) {
CompletableFuture<JsonNode> future = new CompletableFuture<>();
if (id != null) {
messageHandler.startOperation(id, future);
}
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
future.completeExceptionally(e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
int statusCode = response.code();
if (!isExpectedStatusCode(statusCode)) {
future.completeExceptionally(new RuntimeException("Unexpected status code: " + statusCode));
}
// For messages with null ID, we don't wait for a response in the SSE channel
if (id == null) {
future.complete(null);
}
}
});
return future;
}
Response
对象未在使用后调用其 close
方法关闭连接
修复成如下应该就可以了
java
private CompletableFuture<JsonNode> execute(Request request, Long id) {
CompletableFuture<JsonNode> future = new CompletableFuture<>();
if (id != null) {
messageHandler.startOperation(id, future);
}
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
future.completeExceptionally(e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (response) {
int statusCode = response.code();
if (!isExpectedStatusCode(statusCode)) {
future.completeExceptionally(new RuntimeException("Unexpected status code: " + statusCode));
}
// For messages with null ID, we don't wait for a response in the SSE channel
if (id == null) {
future.complete(null);
}
}
}
});
return future;
}
提了一个 issue,本想要提一个 pr,但 langchain4j 的源码在我本地没跑起来,这个机会让给其他有缘人吧