基于 langchain4j 的简易 MCP Client

基于 langchain4j 的简易 MCP Client

前言

这次的定语比较多,又是 简易 又是 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

从官方文档中给的例子中可以看出来一些端倪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);
}

逻辑大概是

  1. 如果 toolProvider 为空,则只包装了 tools 来源的工具

toolSpecifications 和 toolExecutors 是 tools 属性处理得到的

  1. 否则,先将 toolSpecificationstoolExecutors 添加到容器中做准备
  2. 重点在于 provideTools 方法,它将 McpClienttool 列举出来(通过 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>) 源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 没有什么区别

  1. ToolProviderResult 中的 tool 添加到第二步准备好的容器内

剩下就是和 Function Calling 一样,把 tool 的描述交给大模型,让大模型判断应该使用哪些工具来协助完成用户的提问

项目演示

目标

利用 langchain4j 的 MCP Client 能力,使用 高德地图腾讯地图MCP Server 来实现一个旅行规划

准备

大模型换用其它的国内模型也没问题,我习惯用 deepseek 了

其实百度地图也提供了它们的 MCP Server,不过它们的 key 申请需要认证开发者

编码
pom 依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?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 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
组件配置
1
2
3
4
5
6
public interface MapClientService {

@SystemMessage("你是一个旅行规划专家,你可以根据用户的需求,为用户规划出旅行路线。")
Result<String> plan(@MemoryId Long userId, @UserMessage String userMessage);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 手动构建 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。这个日期工具只提供了一个基本的当前日期时间功能

1
2
3
4
5
6
7
8
9
10
11
@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 看看效果。我现在想要知道这个周末在深圳的旅游规划,提示词如下

1
请帮我规划一下这周周末深圳的两天旅行规划,要求精确到小时的安排

这是最后的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
以下是深圳周末两天(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 的响应流未关闭,控制台打印了警告日志

1
2
2025-06-10 17:37: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 17:37: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)如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 方法关闭连接

修复成如下应该就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 的源码在我本地没跑起来,这个机会让给其他有缘人吧

项目代码

GitHub

参考

langchain4j 官网

作者

wuhunyu

发布于

2025-06-10

更新于

2025-06-10

许可协议