基于 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

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

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);
}

逻辑大概是

  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>) 源码如下

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 没有什么区别

  1. 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 的源码在我本地没跑起来,这个机会让给其他有缘人吧

项目代码

GitHub

参考

langchain4j 官网

相关推荐
你的人类朋友1 小时前
🤔Token 存储方案有哪些
前端·javascript·后端
烛阴1 小时前
从零开始:使用Node.js和Cheerio进行轻量级网页数据提取
前端·javascript·后端
liuyang___2 小时前
日期的数据格式转换
前端·后端·学习·node.js·node
保持学习ing4 小时前
SpringBoot前后台交互 -- 登录功能实现(拦截器+异常捕获器)
java·spring boot·后端·ssm·交互·拦截器·异常捕获器
十年老菜鸟4 小时前
spring boot源码和lib分开打包
spring boot·后端·maven
白宇横流学长5 小时前
基于SpringBoot实现的课程答疑系统设计与实现【源码+文档】
java·spring boot·后端
加瓦点灯6 小时前
什么?工作五年还不了解SafePoint?
后端
他日若遂凌云志6 小时前
Lua 模块系统的前世今生:从 module () 到 local _M 的迭代
后端
David爱编程6 小时前
Docker 安全全揭秘:防逃逸、防漏洞、防越权,一篇学会容器防御!
后端·docker·容器
小码编匠7 小时前
WinForm 工业自动化上位机通用框架:注册登录及主界面切换实现
后端·c#·.net