|
目前主流的MCP server的开发语言是python,而在实际业务开发场景中,很多业务的后端开发语言都是Java,如果想在业务中集成各种开箱即用的MCP server,需要打通两种语言之间的壁垒。经过前期调研,发现目前网络中有关Java与MCP结合的资料很少,MCP双端使用不同语言进行开发的更是没有,因此花了一周的时间,通过阅读MCP官方文档、源码,调研目前目前主流的集成MCP的Java开发框架Spring AI,深度探索了Java开发者使用MCP的一些道路,仅供参考。 注意⚠️:Java开发MCP需要的JDK版本至少为17,springboot版本至少为3.0.0。
MCP(Model Context Protocol,模型上下文协议)是一种标准化的通信协议,旨在连接 AI 模型与工具链,提供统一的接口以支持动态工具调用、资源管理、对话状态同步等功能。它允许开发者构建灵活的 AI 应用程序,与不同的模型和工具进行交互,同时保持协议的可扩展性和跨语言兼容性。 - 默认传输(包含在核心
mcp模块中,不需要外部 Web 框架):
- 基于 Java HttpClient 的 SSE 客户端传输,用于 HTTP SSE 客户端流;
- 基于 Servlet 的 SSE 服务器传输,用于 HTTP SSE 服务器流;
- 可选的基于 Spring 的传输(如果使用 Spring 框架则很方便):
- WebFlux SSE 客户端和服务器传输用于响应式 HTTP 流;
- 用于基于 servlet 的 HTTP 流的 WebMVC SSE 传输;
核心io.modelcontextprotocol.sdk:mcp模块提供默认的 STDIO 和 SSE 客户端和服务器传输实现,而无需外部 Web 框架。 为了方便使用 Spring [7]框架,Spring 特定的传输可作为可选依赖项使用。
SDK 遵循分层架构,关注点清晰分离: - 客户端/服务器层(McpClient/McpServer):两者都使用 McpSession 进行同步/异步操作,其中 McpClient 处理客户端协议操作,McpServer 管理服务器端协议操作。
- 会话层(McpSession):使用 DefaultMcpSession 实现管理通信模式和状态。
- 传输层(McpTransport):通过以下方式处理 JSON-RPC 消息序列化/反序列化:
- 核心模块中的 StdioTransport (stdin/stdout);
- 专用传输模块(Java HttpClient、Spring WebFlux、Spring WebMVC)中的 HTTP SSE 传输。
客户端MCP 客户端是模型上下文协议 (MCP) 架构中的关键组件,负责建立和管理与 MCP 服务器的连接。它实现了协议的客户端功能。 MCP 服务器是模型上下文协议 (MCP) 架构中的基础组件,为客户端提供工具、资源和功能。它实现了协议的服务器端。 主要作用: - 客户端/服务器初始化:传输设置、协议兼容性检查、能力协商和实现细节交换。
- 消息流:JSON-RPC 消息处理,带有验证、类型安全响应处理和错误处理。
- 资源管理:资源发现、基于 URI 模板的访问、订阅系统和内容检索。
核心 MCP 功能: <dependency><groupId>io.modelcontextprotocol.sdk</groupId><artifactId>mcp</artifactId></dependency> 核心mcp模块已经包含默认的 STDIO 和 SSE 传输实现,并且不需要外部 Web 框架。 如果使用 Spring 框架并希望使用 Spring 特定的传输实现,添加以下可选依赖项之一: <!-- Optional: Spring WebFlux-based SSE client and server transport --><dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webflux</artifactId></dependency>
<!-- Optional: Spring WebMVC-based SSE server transport --><dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-spring-webmvc</artifactId></dependency>
物料清单 (BOM)物料清单 (BOM) 声明了特定版本使用的所有依赖项的推荐版本。使用应用构建脚本中的 BOM 可以避免自行指定和维护依赖项版本。相反,使用的 BOM 版本决定了所使用的依赖项版本。这还能确保默认使用受支持且经过测试的依赖项版本,除非选择覆盖这些版本。 将 BOM 添加到项目中: <dependencyManagement><dependencies><dependency><groupId>io.modelcontextprotocol.sdk</groupId><artifactId>mcp-bom</artifactId><version>0.9.0</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement> 将版本号替换为要使用的 BOM 版本。 可用的依赖项以下依赖项可用并由 BOM 管理: io.modelcontextprotocol.sdk:mcp- 核心 MCP 库提供模型上下文协议 (MCP) 实现的基本功能和 API,包括默认的 STDIO 和 SSE 客户端及服务器传输实现。无需任何外部 Web 框架。
- 可选的传输依赖项(如果使用 Spring 框架则很方便)
io.modelcontextprotocol.sdk:mcp-spring-webflux- 基于 WebFlux 的服务器发送事件 (SSE) 传输实现,适用于反应式应用程序。io.modelcontextprotocol.sdk:mcp-spring-webmvc- 基于 WebMVC 的服务器发送事件 (SSE) 传输实现,适用于基于 servlet 的应用程序。
io.modelcontextprotocol.sdk:mcp-test- 测试实用程序并支持基于 MCP 的应用程序。
MCP官方提供了模型上下文协议的 Java 实现供Java开发者使用,支持通过同步和异步通信模式与 AI 模型和工具进行标准化交互。官方文档里有详细的server和client开发的指南,大家可自行前往查看学习,不再赘述。 MCP原生Java SDK:https://github.com/modelcontextprotocol/java-sdk MCP 客户端:https://modelcontextprotocol.io/sdk/java/mcp-client MCP 服务端:https://modelcontextprotocol.io/sdk/java/mcp-server 当前绝大部分Java开发者都在使用Spring作为后端开发框架,因此接下来将着重介绍Spring AI中如何集成MCP能力。 SpringAI MCP通过 Spring Boot 集成扩展了 MCP Java SDK,提供客户端[8]和服务器启动器[9]。
Spring AI MCP文档:https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html Spring AI 通过以下 Spring Boot 启动器提供 MCP 集成: 客户端启动器spring-ai-starter-mcp-client- 核心启动器提供 STDIO 和基于 HTTP 的 SSE 支持;
spring-ai-starter-mcp-client-webflux- 基于 WebFlux 的 SSE 传输实现;
服务器启动器spring-ai-starter-mcp-server- 具有 STDIO 传输支持的核心服务器;spring-ai-starter-mcp-server-webmvc- 基于Spring MVC的SSE传输实现;spring-ai-starter-mcp-server-webflux- 基于 WebFlux 的 SSE 传输实现;
spring-ai-starter-mcp太过黑盒,中间的client创建连接等等过程都包装在源码中,且无法自定义,因此只适用于client和server端都是用spring AI开发的mcp应用。 在开发之前,我们需要先了解在MCP通信协议中,一般有两种模式,分别为SSE(Server-Sent Events)和STDIO(标准输入/输出)。 1. 定义- SSE(Server-Sent Events)
基于 HTTP/1.1 的单向推送技术。客户端(浏览器或其他 HTTP 客户端)通过发起一个带Accept: text/event-stream的 GET 请求,与服务器建立一个持久化的连接,服务器可以随时把事件流(文本格式)推送到客户端,客户端通过 JavaScript 的EventSourceAPI监听并处理。 - STDIO(Standard I/O)
操作系统中进程的标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。是进程级的字节流接口,用于命令行程序或脚本间的数据传递,典型用法是管道(|)、重定向(>/<)等。
2. 通信模型3. 典型使用场景SSE- 实时推送:股票行情、微博推送、新消息提示等,需要浏览器端实时更新的场景;
- 简化实现:只需 HTTP,不需要 WebSocket 的握手和多路复用;
STDIO- 命令行工具:如
grep、sed、ffmpeg等通过管道串联,快速处理文本或二进制流; - 脚本自动化:Shell 脚本或进程间的简单数据传输;
4. 优缺点对比总结:- 如果你的目标是在浏览器或 HTTP 客户端中,需要服务器主动推送新事件,且希望自动重连和统一走 HTTP/HTTPS,选SSE最合适;
- 如果你在命令行或本地进程间做高速流式数据处理、管道拼接,并不依赖网络协议,STDIO是最自然也最高效的选择。
serverService类: package com.alibaba.damo.mcpserver.service;
importorg.springframework.ai.tool.annotation.Tool;importorg.springframework.ai.tool.annotation.ToolParam;importorg.springframework.stereotype.Service;importorg.springframework.web.reactive.function.client.WebClient;
/***@authorclong*/@ServicepublicclassOpenMeteoService {
privatefinalWebClientwebClient;
publicOpenMeteoService(WebClient.Builder webClientBuilder){ this.webClient= webClientBuilder .baseUrl("https://api.open-meteo.com/v1") .build(); }
@Tool(description ="根据经纬度获取天气预报") publicStringgetWeatherForecastByLocation( @ToolParam(description ="纬度,例如:39.9042")Stringlatitude, @ToolParam(description ="经度,例如:116.4074")Stringlongitude) {
try{ Stringresponse = webClient.get() .uri(uriBuilder -> uriBuilder .path("/forecast") .queryParam("latitude", latitude) .queryParam("longitude", longitude) .queryParam("current","temperature_2m,wind_speed_10m") .queryParam("timezone","auto") .build()) .retrieve() .bodyToMono(String.class) .block();
// 解析响应并返回格式化的天气信息 return"当前位置(纬度:"+ latitude +",经度:"+ longitude +")的天气信息:\n"+ response; }catch(Exceptione) { return"获取天气信息失败:"+ e.getMessage(); } }
@Tool(description ="根据经纬度获取空气质量信息") publicStringgetAirQuality( @ToolParam(description ="纬度,例如:39.9042")Stringlatitude, @ToolParam(description ="经度,例如:116.4074")Stringlongitude) {
// 模拟数据,实际应用中应调用真实API return"当前位置(纬度:"+ latitude +",经度:"+ longitude +")的空气质量:\n"+ "- PM2.5: 15 μg/m³ (优)\n"+ "- PM10: 28 μg/m³ (良)\n"+ "- 空气质量指数(AQI): 42 (优)\n"+ "- 主要污染物: 无"; }}
启动类: package com.alibaba.damo.mcpserver;
importcom.alibaba.damo.mcpserver.service.OpenMeteoService;importorg.springframework.ai.tool.ToolCallbackProvider;importorg.springframework.ai.tool.method.MethodToolCallbackProvider;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.context.annotation.Bean;importorg.springframework.web.reactive.function.client.WebClient;
@SpringBootApplicationpublicclassMcpServerApplication {
publicstaticvoidmain(String[] args){ SpringApplication.run(McpServerApplication.class, args); }
@Bean publicToolCallbackProviderweatherTools(OpenMeteoService openMeteoService){ returnMethodToolCallbackProvider.builder() .toolObjects(openMeteoService) .build(); }
@Bean publicWebClient.BuilderwebClientBuilder(){ returnWebClient.builder(); }
}
配置文件: server.port=8080
spring.ai.mcp.server.name=my-weather-serverspring.ai.mcp.server.version=0.0.1
依赖: <?xml version="1.0"encoding="UTF-8"?><projectxmlns="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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.alibaba.damo</groupId> <artifactId>mcp-server</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mcp-server</name> <description>mcp-server</description> <properties> <java.version>17</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <spring-boot.version>3.0.2</spring-boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webflux</artifactId> <version>1.0.0-M7</version> </dependency>
<dependency> <groupId>io.netty</groupId> <artifactId>netty-resolver-dns-native-macos</artifactId> <version>4.1.79.Final</version> <scope>runtime</scope> <classifier>osx-aarch_64</classifier> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <configuration> <mainClass>com.alibaba.damo.mcpserver.McpServerApplication</mainClass> <skip>true</skip> </configuration> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
</project>
启动日志: 2025-04-29T20:09:07.153+08:00INFO51478---[main]c.a.damo.mcpserver.McpServerApplication:StartingMcpServerApplicationusingJava17.0.15withPID51478(/Users/clong/IdeaProjects/mcp-server/target/classesstartedbyclongin/Users/clong/IdeaProjects/mcp-server)2025-04-29T20:09:07.154+08:00INFO51478---[main]c.a.damo.mcpserver.McpServerApplication:Noactiveprofileset,fallingbackto1defaultprofile:"default"2025-04-29T20:09:07.561+08:00INFO51478---[main]o.s.a.m.s.a.McpServerAutoConfiguration:Registeredtools:2,notification:true2025-04-29T20:09:07.609+08:00INFO51478---[main]o.s.b.web.embedded.netty.NettyWebServer:Nettystartedonport80802025-04-29T20:09:07.612+08:00INFO51478---[main]c.a.damo.mcpserver.McpServerApplication:StartedMcpServerApplicationin0.572seconds(processrunningfor0.765)2025-04-29T20:10:18.812+08:00INFO51478---[ctor-http-nio-3]i.m.server.McpAsyncServer:Clientinitializerequest-Protocol:2024-11-05,Capabilities:ClientCapabilities[experimental=null,roots=null,sampling=null],Info:Implementation[name=spring-ai-mcp-client-server1,version=1.0.0] client客户端只需要在启动类中构建ChatClient并注入MCP工具即可。 package com.alibaba.damo.mcpclient;
importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.tool.ToolCallbackProvider;importorg.springframework.boot.CommandLineRunner;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.context.ConfigurableApplicationContext;importorg.springframework.context.annotation.Bean;
importjava.util.Arrays;
@SpringBootApplicationpublicclassMcpClientApplication {
publicstaticvoidmain(String[] args){ SpringApplication.run(McpClientApplication.class, args); }
@Bean publicCommandLineRunnerpredefinedQuestions( ChatClient.Builder chatClientBuilder, ToolCallbackProvider tools, ConfigurableApplicationContext context) { returnargs -> { // 构建ChatClient并注入MCP工具 varchatClient = chatClientBuilder .defaultTools(tools) .build();
// 定义用户输入 StringuserInput ="杭州今天天气如何?"; // 打印问题 System.out.println("\n>>> QUESTION: "+ userInput); // 调用LLM并打印响应 System.out.println("\n>>> ASSISTANT: "+ chatClient.prompt(userInput).call().content());
// 关闭应用上下文 context.close(); }; }
}
阿里云百炼平台提供各大模型百万token免费体验,可以直接去平台申请即可获取对应的sk。 https://bailian.console.aliyun.com/console?tab=api#/api spring.ai.openai.api-key=sk-xxxspring.ai.openai.base-url=https://dashscope.aliyuncs.com/compatible-mode/v1spring.ai.openai.chat.options.model=qwen-max
spring.ai.mcp.client.toolcallback.enabled=truespring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080
spring.main.web-application-type=none
依赖中需要添加spring-ai-starter-mcp-client依赖: <?xml version="1.0"encoding="UTF-8"?><projectxmlns="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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.alibaba.damo</groupId> <artifactId>mcp-client</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mcp-client</name> <description>mcp-client</description> <properties> <java.version>17</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <spring-boot.version>3.4.0</spring-boot.version> <mcp.version>0.9.0</mcp.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> <version>1.0.0-M7</version> </dependency> <!-- openai model --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0-M7</version> </dependency> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> <version>0.9.0</version> </dependency>
</dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0-M7</version> <type>pom</type> <scope>import</scope> </dependency> <!-- MCP BOM 统一版本 --> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>${mcp.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <configuration> <mainClass>com.alibaba.damo.mcpclient.McpClientApplication</mainClass> <skip>true</skip> </configuration> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
</project>
客户端调用日志如下: 2025-04-29T20:10:18.302+08:00 INFO51843--- [ main] c.a.damo.mcpclient.McpClientApplication : Starting McpClientApplicationusingJava17.0.15withPID51843(/Users/clong/IdeaProjects/mcp-client/target/classes startedbyclongin/Users/clong/IdeaProjects/mcp-client)2025-04-29T20:10:18.303+08:00 INFO51843--- [ main] c.a.damo.mcpclient.McpClientApplication : No active profileset, falling back to1defaultprofile:"default"2025-04-29T20:10:18.846+08:00 INFO51843--- [ient-1-Worker-0] i.m.client.McpAsyncClient : Server responsewithProtocol:2024-11-05, Capabilities: ServerCapabilities[experimental=null, logging=LoggingCapabilities[], prompts=null, resources=null, tools=ToolCapabilities[listChanged=true]], Info: Implementation[name=my-weather-server, version=0.0.1]andInstructionsnull2025-04-29T20:10:19.018+08:00 INFO51843--- [ main] c.a.damo.mcpclient.McpClientApplication : Started McpClientApplicationin0.842seconds (process running for1.083)
>>> QUESTION: 杭州今天天气如何?
>>> ASSISTANT: 杭州当前的天气信息如下:- 温度:24.4°C- 风速:3.4km/h
请注意,这些信息是基于当前时间的实时数据。
server与SSE模式相比,服务端只需要修改配置文件即可。由于是通过标准输入输出的方式提供服务,服务端不需要开放端口,因此注释掉端口号。同时需要修改web应用类型为none,禁掉banner输出(原因后面会讲)。配置MCP server的类型为stdio,服务名称和版本号,以供客户端发现。 #server.port=8080
spring.main.web-application-type=nonespring.main.banner-mode=off
spring.ai.mcp.server.stdio=truespring.ai.mcp.server.name=my-weather-serverspring.ai.mcp.server.version=0.0.1
修改完之后通过maven package打包成jar文件。 client客户端增加mcp-servers-config.json配置路径,启用toolcallback,注释掉sse连接。 spring.ai.openai.api-key=sk-XXXXXXspring.ai.openai.base-url=https://dashscope.aliyuncs.com/compatible-mode/v1spring.ai.openai.chat.options.model=qwen-max
spring.ai.mcp.client.stdio.servers-configuration=classpath:/mcp-servers-config.jsonspring.ai.mcp.client.toolcallback.enabled=true#spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080
spring.main.web-application-type=none
MCP服务启动配置,这里的jar包为刚刚上面打包的服务端jar包。 {"mcpServers":{"weather":{"command":"java","args":["-Dlogging.pattern.console=","-jar","/Users/clong/IdeaProjects/mcp-server/target/mcp-server-0.0.1-SNAPSHOT.jar"]}}}<?xml version="1.0"encoding="UTF-8"?><projectxmlns="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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.alibaba.damo</groupId> <artifactId>mcp-client</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mcp-client</name> <description>mcp-client</description> <properties> <java.version>17</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <spring-boot.version>3.4.0</spring-boot.version> <mcp.version>0.9.0</mcp.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> <version>1.0.0-M7</version> </dependency> <!-- openai model --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0-M7</version> </dependency><!-- <dependency>--><!-- <groupId>io.modelcontextprotocol.sdk</groupId>--><!-- <artifactId>mcp</artifactId>--><!-- <version>0.9.0</version>--><!-- </dependency>-->
</dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0-M7</version> <type>pom</type> <scope>import</scope> </dependency> <!-- MCP BOM 统一版本 --> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>${mcp.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <configuration> <mainClass>com.alibaba.damo.mcpclient.McpClientApplication</mainClass> <skip>true</skip> </configuration> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
</project>
日志同上,不再打印。 spring.main.web-application-type=none1. Spring Boot 自动配置与 WebApplicationTypeSpring Boot 在启动时会根据类路径自动检测应用类型(ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">WebApplicationType),并加载对应的自动配置: - 若检测到 WebFlux 相关依赖,则创建
ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ReactiveWebApplicationContext,并尝试注册ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ReactiveWebServerFactory; - 若检测到 Servlet(Spring MVC)相关依赖,则创建:
ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">
ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ServletWebServerApplicationContext,并尝试注册ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ServletWebServerFactory; - 若未检测到任何 Web 依赖,或显式设置为
ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">NONE,则不会初始化任何内嵌 Web 容器。
2. 缺少对应的 Starter 依赖- 缺少 WebFlux Starter:若项目未引入
ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">spring-boot-starter-webflux,则不会创建ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ReactiveWebServerFactory,导致启动ingFang SC", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.034em;font-style: normal;font-weight: normal;">ReactiveWebApplicationContext时抛出缺失 Bean 异常;
- 缺少 Servlet Starter:同理,若项目未引入
spring-boot-starter-web,则不会创建ServletWebServerFactory,会在启动ServletWebServerApplicationContext时抛出类似错误。
3. 应用类型与 Starter 冲突当同时引入了spring-web(Servlet)和spring-webflux(Reactive)依赖时,Spring Boot 默认优先选择 Servlet 模式;若业务需要 Reactive,可显式设置spring.main.web-application-type=reactive,否则仍然会走 Servlet 自动配置路径。 因此我们需要将该配置项设置为none,避免WebFlux或者Servlet容器报找不到错误。 spring.main.banner-mode=offMCP 客户端通过 STDIO 读取 JSON-RPC 消息时,会将 Spring Boot 的启动 Banner(ASCII 艺术 Logo)或其他非 JSON 文本内容当作输入交给 Jackson 解析,导致MismatchedInputException: No content to map due to end-of-input异常。为彻底避免非 JSON 文本污染标准输出流,需要在 Spring Boot 应用中禁用 Banner 输出,即在application.properties中配置spring.main.banner-mode=off,或在代码中通过SpringApplication设置Banner.Mode.OFF。 spring.ai.mcp.client.toolcallback.enabled=truespring.ai.mcp.client.toolcallback.enabled用于显式开启 Spring AI 与 Model Context Protocol (MCP) 之间的工具回调(ToolCallback)集成;该功能默认关闭,必须显式设置为true才会激活相应的自动配置并注册ToolCallbackProviderBean,以便在 ChatClient 中注入并使用 MCP 工具。
在实际开发过程中,对于上述两种模式,STDIO更加倾向于demo,对于企业级应用及大规模部署,采用SSE远程通信的方式可扩展性更强,且更加灵活,实现服务端与客户端的完全解耦。因此接下来我们默认采用SSE的模式来构建MCP通信。目前市面上绝大部分的MCP server代码都是用python开发的(AI时代加速了python的发展),对于Java开发者来说,我们想要实现最好不修改一行代码,无缝对接这些服务。 Model Context Protocol(MCP)基于 JSON-RPC 2.0,完全与语言无关,支持通过标准化的消息格式在任意编程语言间互通。因此,Java实现的 MCP 客户端可以无缝地与Python实现的 MCP 服务器通信,只要双方遵循相同的协议规范和传输方式即可。 1.1 基于 JSON-RPC 2.0MCP 的底层通信协议是 JSON-RPC 2.0,它使用纯文本的 JSON 作为编码格式,极大地保证了跨语言互操作性。任何能读写 JSON 并通过 TCP/STDIO/HTTP/WebSocket 等传输层发送、接收文本的语言,都能实现对 MCP 消息的编解码。 1.2 官方多语言 SDKAnthropic 和社区已经提供了多语言的 MCP SDK,包括 Python、Java、TypeScript、Kotlin、C# 等。各 SDK 都会对 JSON-RPC 消息进行封装,使得开发者只需调用相应方法即可,而无需关心底层细节。 MCP 消息既可通过标准输入/输出(STDIO)传输,也可通过HTTP(S)或WebSocket进行通信。只要双方选用一致的传输通道,Java 客户端和 Python 服务器就能正常交换 JSON-RPC 消息。 为了方便起见,这里的MCP服务端使用blender-mcp作为SSE服务端。 3.1 Spring AI问题如果使用Spring AI开发的MCP客户端连接python开发的MCP服务端请求会报错。 python端报错: 2025-04-30T17:25:09.843+08:00ERROR69857---[onPool-worker-1]i.m.c.t.HttpClientSseClientTransport:Errorsendingmessage:500 Java端报错: INFO: 127.0.0.1:55085-"GET /sse HTTP/1.1"200OKWARNING: Unsupported upgrade request.INFO: 127.0.0.1:55087-"POST /messages/?session_id=5b92a6377fcb4b3fa9f051b43d0379b5 HTTP/1.1"500Internal Server ErrorERROR: ExceptioninASGI applicationTraceback(most recent call last): File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/uvicorn/protocols/http/httptools_impl.py", line409,inrun_asgi result =awaitapp( # type: ignore[func-returns-value] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ self.scope, self.receive, self.send ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/uvicorn/middleware/proxy_headers.py", line60,in__call__ returnawaitself.app(scope, receive, send) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/applications.py", line112,in__call__ awaitself.middleware_stack(scope, receive, send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/middleware/errors.py", line187,in__call__ raiseexc File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/middleware/errors.py", line165,in__call__ awaitself.app(scope, receive, _send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line62,in__call__ awaitwrap_app_handling_exceptions(self.app, conn)(scope, receive, send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line53,inwrapped_app raiseexc File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/_exception_handler.py", line42,inwrapped_app awaitapp(scope, receive, sender) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/routing.py", line714,in__call__ awaitself.middleware_stack(scope, receive, send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/routing.py", line734,inapp awaitroute.handle(scope, receive, send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/routing.py", line460,inhandle awaitself.app(scope, receive, send) File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/mcp/server/sse.py", line159,inhandle_post_message json =awaitrequest.json() ^^^^^^^^^^^^^^^^^^^^ File"/Users/clong/PycharmProjects/blender-mcp/.venv/lib/python3.13/site-packages/starlette/requests.py", line248,injson self._json = json.loads(body) ~~~~~~~~~~^^^^^^ File"/Users/clong/.local/share/uv/python/cpython-3.13.2-macos-aarch64-none/lib/python3.13/json/__init__.py", line346,inloads return_default_decoder.decode(s) ~~~~~~~~~~~~~~~~~~~~~~~^^^ File"/Users/clong/.local/share/uv/python/cpython-3.13.2-macos-aarch64-none/lib/python3.13/json/decoder.py", line345,indecode obj, end = self.raw_decode(s, idx=_w(s,0).end()) ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ File"/Users/clong/.local/share/uv/python/cpython-3.13.2-macos-aarch64-none/lib/python3.13/json/decoder.py", line363,inraw_decode raiseJSONDecodeError("Expecting value", s, err.value)fromNonejson.decoder.JSONDecodeError: Expecting value: line1column1(char0)
原因追源码发现Java MCP client在初始化阶段发送POST请求(见第二段代码的28行)body为空,导致python MCP server端json反序列化(第一段代码27行)空字符串b''失败。暂时无法解决。如果想要用Spring AI,就需要重写mcp server。 asyncdefhandle_post_message( self, scope: Scope, receive: Receive, send: Send) ->None: logger.debug("Handling POST message") request = Request(scope, receive)
session_id_param = request.query_params.get("session_id") ifsession_id_paramisNone: logger.warning("Received request without session_id") response = Response("session_id is required", status_code=400) returnawaitresponse(scope, receive, send)
try: session_id = UUID(hex=session_id_param) logger.debug(f"Parsed session ID:{session_id}") exceptValueError: logger.warning(f"Received invalid session ID:{session_id_param}") response = Response("Invalid session ID", status_code=400) returnawaitresponse(scope, receive, send)
writer = self._read_stream_writers.get(session_id) ifnot writer: logger.warning(f"Could not find session for ID:{session_id}") response = Response("Could not find session", status_code=404) returnawaitresponse(scope, receive, send)
json =awaitrequest.json() logger.debug(f"Received JSON:{json}")
try: message = types.JSONRPCMessage.model_validate(json) logger.debug(f"Validated client message:{message}") exceptValidationErroraserr: logger.error(f"Failed to parse message:{err}") response = Response("Could not parse message", status_code=400) awaitresponse(scope, receive, send) awaitwriter.send(err) return
logger.debug(f"Sending message to writer:{message}") response = Response("Accepted", status_code=202) awaitresponse(scope, receive, send) awaitwriter.send(message)
@OverridepublicMono<Void> sendMessage(JSONRPCMessage message){ if(isClosing) { returnMono.empty(); }
try{ if(!closeLatch.await(10, TimeUnit.SECONDS)) { returnMono.error(new McpError("Failed to wait for the message endpoint")); } } catch(InterruptedException e) { returnMono.error(new McpError("Failed to wait for the message endpoint")); }
String endpoint = messageEndpoint.get(); if(endpoint ==null) { returnMono.error(new McpError("No message endpoint available")); }
try{ String jsonText =this.objectMapper.writeValueAsString(message); HttpRequest request =this.requestBuilder.uri(URI.create(this.baseUri + endpoint)) .POST(HttpRequest.BodyPublishers.ofString(jsonText)) .build();
returnMono.fromFuture( httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).thenAccept(response -> { if(response.statusCode() !=200&& response.statusCode() !=201&& response.statusCode() !=202 && response.statusCode() !=206) { logger.error("Error sending message: {}", response.statusCode()); } })); } catch(IOException e) { if(!isClosing) { returnMono.error(new RuntimeException("Failed to serialize message", e)); } returnMono.empty(); }}
重写server的方式比较繁琐且不适用,对于目前绝大部分的MCP server都是python开发的现状下,尽量不动mcp server端的代码最好,因此,我尝试从client端着手,抛弃spring AI的包装,尝试使用原生的mcp java sdk+openai java sdk来实现一个类似Claude desktop这样的支持MCP调用的client。 3.2 mcp java sdk 代码实现package com.alibaba.damo.mcpclient.client;
importcom.openai.client.OpenAIClient;importcom.openai.client.okhttp.OpenAIOkHttpClient;importcom.openai.core.JsonValue;importcom.openai.models.FunctionDefinition;importcom.openai.models.FunctionParameters;importcom.openai.models.chat.completions.*;importio.modelcontextprotocol.client.McpClient;importio.modelcontextprotocol.client.McpSyncClient;importio.modelcontextprotocol.client.transport.HttpClientSseClientTransport;importio.modelcontextprotocol.spec.McpClientTransport;importio.modelcontextprotocol.spec.McpSchema;importjakarta.annotation.PostConstruct;importjakarta.annotation.PreDestroy;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.stereotype.Service;importjava.util.ArrayList;importjava.util.Arrays;importjava.util.HashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;importjava.util.stream.Collectors;importstaticcom.openai.models.chat.completions.ChatCompletion.Choice.FinishReason.TOOL_CALLS;/*** @author clong*/@ServicepublicclassMyMCPClient{privatestaticfinalLogger logger = LoggerFactory.getLogger(MyMCPClient.class); @Value("${spring.ai.openai.base-url}")privateString baseUrl; @Value("${spring.ai.openai.api-key}")privateString apiKey; @Value("${spring.ai.openai.chat.options.model}")privateString model;// Tool 名称到 MCP Client 的映射privatefinalMap<String, McpSyncClient> toolToClient =newHashMap<>(); @Value("${mcp.servers}") // e.g. tool1=http://url1,tool2=http://url2,...privateString toolServerMapping;privateOpenAIClient openaiClient;privatefinalList<McpSchema.Tool> allTools =newArrayList<>(); @PostConstructpublicvoidinit(){// 解析配置并初始化各 MCP Client Arrays.stream(toolServerMapping.split(",")) .map(entry -> entry.split("=")) .forEach(pair-> { String url =pair[1]; McpClientTransport transport = HttpClientSseClientTransport.builder(url).build(); McpSyncClient client = McpClient.sync(transport).build(); client.initialize(); // 建立 SSE 连接 logger.info("Connected to MCP server via SSE at {}", url);// 列出并打印所有可用工具 List<McpSchema.Tool> tools = client.listTools().tools(); logger.info("Available MCP tools:"); tools.forEach(t -> logger.info(" - {} : {}", t.name(), t.description())); allTools.addAll(tools); tools.forEach(t -> toolToClient.put(t.name(), client)); });// 2. 初始化 OpenAI 客户端this.openaiClient = OpenAIOkHttpClient.builder() .baseUrl(baseUrl) .apiKey(apiKey) .checkJacksonVersionCompatibility(false) .build(); logger.info("OpenAI client initialized with model {}", model); } @PreDestroypublicvoidshutdown(){// 如果有必要,优雅关闭 MCP 客户端 toolToClient.values().forEach((client) -> {try{ client.close(); logger.info("Closed MCP client for {}", client); }catch(Exception e) { logger.warn("Error closing MCP client for {}: {}", client, e.getMessage()); } }); }/** * 处理一次用户查询:注入所有工具定义 -> 发首轮请求 -> 若触发 function_call 则执行 -> * 再次发请求获取最终回复 */publicStringprocessQuery(String query){try{ List<ChatCompletionTool> chatTools = allTools.stream() .map(t -> ChatCompletionTool.builder() .function(FunctionDefinition.builder() .name(t.name()) .description(t.description()) .parameters(FunctionParameters.builder() .putAdditionalProperty("type", JsonValue.from(t.inputSchema().type())) .putAdditionalProperty("properties", JsonValue.from(t.inputSchema().properties())) .putAdditionalProperty("required", JsonValue.from(t.inputSchema().required())) .putAdditionalProperty("additionalProperties", JsonValue.from(t.inputSchema().additionalProperties())) .build()) .build()) .build()) .toList();// 2. 构建对话参数 ChatCompletionCreateParams.Builder builder = ChatCompletionCreateParams.builder() .model(model) .maxCompletionTokens(1000) .tools(chatTools) .addUserMessage(query);// 3. 首次调用(可能包含 function_call) ChatCompletion initial = openaiClient.chat() .completions() .create(builder.build());// 4. 处理模型回复 List<ChatCompletion.Choice> choices = initial.choices();if(choices.isEmpty()) {return"[Error] empty response from model"; } ChatCompletion.Choice first = choices.get(0);// 如果模型触发了 function_callwhile(first.finishReason().equals(TOOL_CALLS)) { ChatCompletionMessage msg = first.message();// 如果同时触发了多个工具调用,toolCalls() 会返回一个列表 List<ChatCompletionMessageToolCall> calls = msg .toolCalls() // Optional<List<...>>// 若无调用则空列表 .orElse(List.of()); builder.addMessage(msg);for(ChatCompletionMessageToolCall call : calls) { ChatCompletionMessageToolCall.Function fn = call.function();// 执行 MCP 工具 String toolResult = callMcpTool(fn.name(), fn.arguments()); logger.info("Tool {} returned: {}", fn.name(), toolResult);// 将 function_call 与工具执行结果注入上下文 builder.addMessage(ChatCompletionToolMessageParam.builder() .toolCallId(Objects.requireNonNull(msg.toolCalls().orElse(null)).get(0).id()) .content(toolResult) .build()); }// 5. 二次调用,拿最终回复 ChatCompletion followup = openaiClient.chat() .completions() .create(builder.build()); first = followup.choices().get(0); }// 若未触发函数调用,直接返回文本returnfirst.message().content().orElse("无返回文本"); }catch(Exception e) { logger.error("Unexpected error during processQuery", e);return"[Error] "+ e.getMessage(); } }/** * 调用 MCP Server 上的工具并返回结果文本 */privateStringcallMcpTool(String name, String arguments){try{ McpSchema.CallToolRequest req =newMcpSchema.CallToolRequest(name, arguments);returntoolToClient.get(name).callTool(req) .content() .stream() .map(Object::toString) .collect(Collectors.joining("\n")); }catch(Exception e) { logger.error("Failed to call MCP tool {}: {}", name, e.getMessage());return"[Tool Error] "+ e.getMessage(); } }}
package com.alibaba.damo.mcpclient.client;
importcom.openai.client.OpenAIClient;importcom.openai.client.okhttp.OpenAIOkHttpClient;importcom.openai.core.JsonValue;importcom.openai.models.FunctionDefinition;importcom.openai.models.FunctionParameters;importcom.openai.models.chat.completions.*;importio.modelcontextprotocol.client.McpClient;importio.modelcontextprotocol.client.McpSyncClient;importio.modelcontextprotocol.client.transport.HttpClientSseClientTransport;importio.modelcontextprotocol.spec.McpClientTransport;importio.modelcontextprotocol.spec.McpSchema;importjakarta.annotation.PostConstruct;importjakarta.annotation.PreDestroy;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.stereotype.Service;importjava.util.ArrayList;importjava.util.Arrays;importjava.util.HashMap;importjava.util.List;importjava.util.Map;importjava.util.Objects;importjava.util.stream.Collectors;importstatic com.openai.models.chat.completions.ChatCompletion.Choice.FinishReason.TOOL_CALLS;/***@authorclong*/@ServicepublicclassMyMCPClient { privatestaticfinalLoggerlogger =LoggerFactory.getLogger(MyMCPClient.class); @Value("${spring.ai.openai.base-url}") privateStringbaseUrl; @Value("${spring.ai.openai.api-key}") privateStringapiKey; @Value("${spring.ai.openai.chat.options.model}") privateStringmodel; // Tool 名称到 MCP Client 的映射 privatefinalMap<String,McpSyncClient> toolToClient =newHashMap<>(); @Value("${mcp.servers}") // e.g. tool1=http://url1,tool2=http://url2,... privateStringtoolServerMapping; privateOpenAIClientopenaiClient; privatefinalList<McpSchema.Tool> allTools =newArrayList<>(); @PostConstruct publicvoidinit(){ // 解析配置并初始化各 MCP Client Arrays.stream(toolServerMapping.split(",")) .map(entry -> entry.split("=")) .forEach(pair -> { Stringurl = pair[1]; McpClientTransporttransport = HttpClientSseClientTransport.builder(url).build(); McpSyncClientclient =McpClient.sync(transport).build(); client.initialize(); // 建立 SSE 连接 logger.info("Connected to MCP server via SSE at {}", url); // 列出并打印所有可用工具 List<McpSchema.Tool> tools = client.listTools().tools(); logger.info("Available MCP tools:"); tools.forEach(t -> logger.info(" - {} : {}", t.name(), t.description())); allTools.addAll(tools); tools.forEach(t -> toolToClient.put(t.name(), client)); }); // 2. 初始化 OpenAI 客户端 this.openaiClient=OpenAIOkHttpClient.builder() .baseUrl(baseUrl) .apiKey(apiKey) .checkJacksonVersionCompatibility(false) .build(); logger.info("OpenAI client initialized with model {}", model); } @PreDestroy publicvoidshutdown(){ // 如果有必要,优雅关闭 MCP 客户端 toolToClient.values().forEach((client) -> { try{ client.close(); logger.info("Closed MCP client for {}", client); }catch(Exceptione) { logger.warn("Error closing MCP client for {}: {}", client, e.getMessage()); } }); } /** * 处理一次用户查询:注入所有工具定义 -> 发首轮请求 -> 若触发 function_call 则执行 -> * 再次发请求获取最终回复 */ publicStringprocessQuery(Stringquery){ try{ List<ChatCompletionTool> chatTools = allTools.stream() .map(t ->ChatCompletionTool.builder() .function(FunctionDefinition.builder() .name(t.name()) .description(t.description()) .parameters(FunctionParameters.builder() .putAdditionalProperty("type", JsonValue.from(t.inputSchema().type())) .putAdditionalProperty("properties", JsonValue.from(t.inputSchema().properties())) .putAdditionalProperty("required", JsonValue.from(t.inputSchema().required())) .putAdditionalProperty("additionalProperties", JsonValue.from(t.inputSchema().additionalProperties())) .build()) .build()) .build()) .toList(); // 2. 构建对话参数 ChatCompletionCreateParams.Builderbuilder =ChatCompletionCreateParams.builder() .model(model) .maxCompletionTokens(1000) .tools(chatTools) .addUserMessage(query); // 3. 首次调用(可能包含 function_call) ChatCompletioninitial = openaiClient.chat() .completions() .create(builder.build()); // 4. 处理模型回复 List<ChatCompletion.Choice> choices = initial.choices(); if(choices.isEmpty()) { return"[Error] empty response from model"; } ChatCompletion.Choicefirst = choices.get(0); // 如果模型触发了 function_call while(first.finishReason().equals(TOOL_CALLS)) { ChatCompletionMessagemsg = first.message(); // 如果同时触发了多个工具调用,toolCalls() 会返回一个列表 List<ChatCompletionMessageToolCall> calls = msg .toolCalls() // Optional<List<...>> // 若无调用则空列表 .orElse(List.of()); builder.addMessage(msg); for(ChatCompletionMessageToolCallcall : calls) { ChatCompletionMessageToolCall.Functionfn = call.function(); // 执行 MCP 工具 StringtoolResult =callMcpTool(fn.name(), fn.arguments()); logger.info("Tool {} returned: {}", fn.name(), toolResult); // 将 function_call 与工具执行结果注入上下文 builder.addMessage(ChatCompletionToolMessageParam.builder() .toolCallId(Objects.requireNonNull(msg.toolCalls().orElse(null)).get(0).id()) .content(toolResult) .build()); } // 5. 二次调用,拿最终回复 ChatCompletionfollowup = openaiClient.chat() .completions() .create(builder.build()); first = followup.choices().get(0); } // 若未触发函数调用,直接返回文本 returnfirst.message().content().orElse("无返回文本"); }catch(Exceptione) { logger.error("Unexpected error during processQuery", e); return"[Error] "+ e.getMessage(); } } /** * 调用 MCP Server 上的工具并返回结果文本 */ privateStringcallMcpTool(Stringname,Stringarguments){ try{ McpSchema.CallToolRequestreq =newMcpSchema.CallToolRequest(name,arguments); returntoolToClient.get(name).callTool(req) .content() .stream() .map(Object::toString) .collect(Collectors.joining("\n")); }catch(Exceptione) { logger.error("Failed to call MCP tool {}: {}", name, e.getMessage()); return"[Tool Error] "+ e.getMessage(); } }}
<?xml version="1.0"encoding="UTF-8"?><projectxmlns="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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.alibaba.damo</groupId> <artifactId>mcp-client</artifactId> <version>0.0.1-SNAPSHOT</version> <name>mcp-client</name> <description>mcp-client</description> <properties> <java.version>17</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <spring-boot.version>3.4.0</spring-boot.version> <mcp.version>0.9.0</mcp.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>com.openai</groupId> <artifactId>openai-java</artifactId> <version>1.6.0</version> </dependency>
</dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0-M7</version> <type>pom</type> <scope>import</scope> </dependency> <!-- MCP BOM 统一版本 --> <dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp-bom</artifactId> <version>${mcp.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <configuration> <mainClass>com.alibaba.damo.mcpclient.McpClientApplication</mainClass> <skip>true</skip> </configuration> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
</project>
spring.ai.openai.api-key=sk-xxxxspring.ai.openai.base-url=https://dashscope.aliyuncs.com/compatible-mode/v1spring.ai.openai.chat.options.model=qwen-max
mcp.servers=blender=http://localhost:8000
下面重点梳理processQuery方法中的核心逻辑流程,可分为三大步骤: 1. 将 MCP 工具注册为 OpenAI Function- 遍历
allTools(从所有 MCP Server 拉取到的工具列表),把每个工具的名称、描述和输入参数 JSON Schema 封装成 OpenAI SDK 可识别的FunctionDefinition对象; - 构造
ChatCompletionCreateParams时,将这些FunctionDefinition作为tools传入,告诉模型“你可以调这些外部工具”。
2. 首次发起 ChatCompletion 请求- 使用指定的模型(如
gpt-4o-mini)、最大 token 限制和用户提问query,调用openaiClient.chat().completions().create(...);
- 或者一个
function_call,即模型决定调用某个工具来获取更准确的数据。
3. 循环处理 Function Call → 工具执行 → 再次调用while(first.finishReason()==TOOL_CALLS){1.从模型回复中提取function_call(函数名+参数JSON)2.调用callMcpTool(name,args):把请求发给对应的MCPServer,同步拿回执行结果文本3.将模型的function_call消息和工具执行结果消息依次注入对话上下文(builder.addMessage(...))4.用更新后的上下文,再次调用openaiClient.chat().completions().create(...),获取新的`first`}- 通过这个循环,模型能够“问了就答、答了再问、再答”,直到它不再触发
function_call,而是以纯文本的形式给出最终响应; - 最终,取出
first.message().content()作为完整回答返回。
核心优势: - 动态工具调用:让 LLM 在对话中主动“点”外部工具,获取实时、可信的数据;
- 对话式编排:多轮注入上下文,不丢失模型的思考链路,保证回答连贯;
- 解耦清晰:把“模型对话”和“工具执行”分离,用循环机制优雅衔接。
测试启动springboot服务,通过controller接口post请求测试。 @RestControllerpublicclassController { @Autowired privateMyMCPClientmyMCPClient;
@PostMapping("/client") publicStringclient(@RequestParam("query")Stringquery){ returnmyMCPClient.processQuery(query); }}
client端日志: 2025-05-07T17:35:42.974+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Toolget_scene_inforeturned:TextContent[audience=null,priority=null,text={"name":"Scene","object_count":3,"objects":[{"name":"WaterCup","type":"MESH","location":[0.0,0.0,0.0]},{"name":"Camera","type":"CAMERA","location":[4.0,-4.0,3.0]},{"name":"MainLight","type":"LIGHT","location":[2.0,-2.0,3.0]}],"materials_count":12}]2025-05-07T17:35:46.371+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Toolget_hyper3d_statusreturned:TextContent[audience=null,priority=null,text=Hyper3DRodinintegrationiscurrentlydisabled.Toenableit:1.Inthe3DViewport,findtheBlenderMCPpanelinthesidebar(pressNifhidden)2.Checkthe'UseHyper3DRodin3Dmodelgeneration'checkbox3.RestarttheconnectiontoClaude]2025-05-07T17:35:50.276+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Tooldelete_objectreturned:TextContent[audience=null,priority=null,text=Deletedobject:WaterCup]2025-05-07T17:35:54.286+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Tooldelete_objectreturned:TextContent[audience=null,priority=null,text=Deletedobject:Camera]2025-05-07T17:35:58.516+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Tooldelete_objectreturned:TextContent[audience=null,priority=null,text=Deletedobject:MainLight]2025-05-07T17:36:23.889+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Toolexecute_blender_codereturned:TextContent[audience=null,priority=null,text=Codeexecutedsuccessfully:]2025-05-07T17:36:27.330+08:00INFO3127---[nio-8080-exec-4]c.a.damo.mcpclient.client.MyMCPClient:Toolsave_scenereturned:TextContent[audience=null,priority=null,text=Scenesavedto/Users/clong/Pictures/pig.blend]server端部分核心日志 2025-05-0717:36:23,716-BlenderMCPServer-INFO-Sendingcommand:execute_codewithparams:{'code':'importbpy\nimportmath\n\n#Createthepig\'sbody\nbpy.ops.mesh.primitive_uv_sphere_add(radius=1,location=(0,0,0.8))\nbody=bpy.context.active_object\nbody.name="Pig_Body"\nbody.scale=(1.5,1,0.8)\n\n#Createthepig\'shead\nbpy.ops.mesh.primitive_uv_sphere_add(radius=0.7,location=(1.5,0,1.2))\nhead=bpy.context.active_object\nhead.name="Pig_Head"\n\n#Createthepig\'ssnout\nbpy.ops.mesh.primitive_cylinder_add(radius=0.3,depth=0.4,location=(2.0,0,1.0))\nsnout=bpy.context.active_object\nsnout.name="Pig_Snout"\nsnout.rotation_euler=(math.radians(90),0,0)\n\n#Createthenostrils\nbpy.ops.mesh.primitive_cylinder_add(radius=0.08,depth=0.1,location=(2.2,0.15,1))\nleft_nostril=bpy.context.active_object\nleft_nostril.name="Pig_Nostril_Left"\nleft_nostril.rotation_euler=(math.radians(90),0,0)\n\nbpy.ops.mesh.primitive_cylinder_add(radius=0.08,depth=0.1,location=(2.2,-0.15,1))\nright_nostril=bpy.context.active_object\nright_nostril.name="Pig_Nostril_Right"\nright_nostril.rotation_euler=(math.radians(90),0,0)\n\n#Createtheeyes\nbpy.ops.mesh.primitive_uv_sphere_add(radius=0.1,location=(1.9,0.3,1.5))\nleft_eye=bpy.context.active_object\nleft_eye.name="Pig_Eye_Left"\n\nbpy.ops.mesh.primitive_uv_sphere_add(radius=0.1,location=(1.9,-0.3,1.5))\nright_eye=bpy.context.active_object\nright_eye.name="Pig_Eye_Right"\n\n#Createtheears\nbpy.ops.mesh.primitive_cone_add(radius1=0.3,radius2=0,depth=0.5,location=(1.3,0.5,1.8))\nleft_ear=bpy.context.active_object\nleft_ear.name="Pig_Ear_Left"\nleft_ear.rotation_euler=(math.radians(-30),math.radians(-20),math.radians(20))\n\nbpy.ops.mesh.primitive_cone_add(radius1=0.3,radius2=0,depth=0.5,location=(1.3,-0.5,1.8))\nright_ear=bpy.context.active_object\nright_ear.name="Pig_Ear_Right"\nright_ear.rotation_euler=(math.radians(-30),math.radians(20),math.radians(-20))\n\n#Createthelegs\ndefcreate_leg(name,x,y):\nbpy.ops.mesh.primitive_cylinder_add(radius=0.2,depth=0.7,location=(x,y,0.3))\nleg=bpy.context.active_object\nleg.name=name\nreturnleg\n\nfront_left_leg=create_leg("Pig_Leg_Front_Left",0.7,0.5)\nfront_right_leg=create_leg("Pig_Leg_Front_Right",0.7,-0.5)\nback_left_leg=create_leg("Pig_Leg_Back_Left",-0.7,0.5)\nback_right_leg=create_leg("Pig_Leg_Back_Right",-0.7,-0.5)\n\n#Createthetail\nbpy.ops.curve.primitive_bezier_curve_add(location=(-1.5,0,0.8))\ntail=bpy.context.active_object\ntail.name="Pig_Tail"\n\n#Modifythetailcurve\ntail.data.bevel_depth=0.05\ntail.data.bevel_resolution=4\ntail.data.fill_mode=\'FULL\'\n\n#Setthecurvepoints\npoints=tail.data.splines[0].bezier_points\npoints[0].co=(-1.5,0,0.8)\npoints[0].handle_left=(-1.5,-0.2,0.7)\npoints[0].handle_right=(-1.5,0.2,0.9)\npoints[1].co=(-1.8,0.3,1.0)\npoints[1].handle_left=(-1.7,0.1,1.0)\npoints[1].handle_right=(-1.9,0.5,1.0)\n\n#Createthemainpigmaterial\npig_mat=bpy.data.materials.new(name="Pig_Material")\npig_mat.diffuse_color=(0.9,0.6,0.6,1.0)\n\n#Createtheeyematerial\neye_mat=bpy.data.materials.new(name="Eye_Material")\neye_mat.diffuse_color=(0.0,0.0,0.0,1.0)\n\n#Createthenostrilmaterial\nnostril_mat=bpy.data.materials.new(name="Nostril_Material")\nnostril_mat.diffuse_color=(0.2,0.0,0.0,1.0)\n\n#Applymaterials\nforobjinbpy.data.objects:\nifobj.name.startswith("Pig_")andnotobj.name.startswith("Pig_Eye")andnotobj.name.startswith("Pig_Nostril"):\nifobj.data.materials:\nobj.data.materials[0]=pig_mat\nelse:\nobj.data.materials.append(pig_mat)\n\nifobj.name.startswith("Pig_Eye"):\nifobj.data.materials:\nobj.data.materials[0]=eye_mat\nelse:\nobj.data.materials.append(eye_mat)\n\nifobj.name.startswith("Pig_Nostril"):\nifobj.data.materials:\nobj.data.materials[0]=nostril_mat\nelse:\nobj.data.materials.append(nostril_mat)\n\n#Createanewcollectionforthepig\npig_collection=bpy.data.collections.new("Pig")\nbpy.context.scene.collection.children.link(pig_collection)\n\n#Addallpigobjectstothecollection\nforobjinbpy.data.objects:\nifobj.name.startswith("Pig_"):\n#Firstremovefromcurrentcollection\nforcollinobj.users_collection:\ncoll.objects.unlink(obj)\n#Thenaddtothepigcollection\npig_collection.objects.link(obj)\n\n#Addanewcamera\nbpy.ops.object.camera_add(location=(5,-5,3))\ncamera=bpy.context.active_object\ncamera.name="Camera"\ncamera.rotation_euler=(math.radians(60),0,math.radians(45))\nbpy.context.scene.camera=camera\n\n#Addalight\nbpy.ops.object.light_add(type=\'SUN\',location=(5,5,10))\nlight=bpy.context.active_object\nlight.name="Sun"\nlight.rotation_euler=(math.radians(45),math.radians(45),0)'}2025-05-0717:36:23,718-BlenderMCPServer-INFO-Commandsent,waitingforresponse...2025-05-0717:36:23,884-BlenderMCPServer-INFO-Receivedcompleteresponse(51bytes)2025-05-0717:36:23,884-BlenderMCPServer-INFO-Received51bytesofdata2025-05-0717:36:23,884-BlenderMCPServer-INFO-Responseparsed,status:success2025-05-0717:36:23,884-BlenderMCPServer-INFO-Responseresult:{'executed':True}INFO:127.0.0.1:51875-"POST/messages/?session_id=fa4b43638d574203b05d40cd283c78cfHTTP/1.1"202Accepted2025-05-0717:36:27,020-mcp.server.lowlevel.server-INFO-ProcessingrequestoftypeCallToolRequest2025-05-0717:36:27,020-BlenderMCPServer-INFO-Sendingcommand:get_polyhaven_statuswithparams:None2025-05-0717:36:27,021-BlenderMCPServer-INFO-Commandsent,waitingforresponse...2025-05-0717:36:27,121-BlenderMCPServer-INFO-Receivedcompleteresponse(115bytes)2025-05-0717:36:27,121-BlenderMCPServer-INFO-Received115bytesofdata2025-05-0717:36:27,121-BlenderMCPServer-INFO-Responseparsed,status:success2025-05-0717:36:27,121-BlenderMCPServer-INFO-Responseresult:{'enabled':True,'message':'PolyHavenintegrationisenabledandreadytouse.'}2025-05-0717:36:27,121-BlenderMCPServer-INFO-Sendingcommand:save_scenewithparams:{'filepath':'/Users/clong/Pictures/pig.blend'}2025-05-0717:36:27,121-BlenderMCPServer-INFO-Commandsent,waitingforresponse...2025-05-0717:36:27,326-BlenderMCPServer-INFO-Receivedcompleteresponse(80bytes)2025-05-0717:36:27,327-BlenderMCPServer-INFO-Received80bytesofdata2025-05-0717:36:27,327-BlenderMCPServer-INFO-Responseparsed,status:success2025-05-0717:36:27,327-BlenderMCPServer-INFO-Responseresult:{'filepath':'/Users/clong/Pictures/pig.blend'}已经保存到本地指定目录下 使用blender打开即可看到一头粉色的小猪 本文系统性地探索了 Java 开发者在 MCP(Model Context Protocol)生态中的实践路径,从背景调研到技术验证,从 Spring AI 的局限性到原生 MCP SDK 的深度整合,最终实现了 Java 客户端与 Python 服务端的无缝协作。这一过程不仅验证了 MCP 协议的跨语言通用性,也为企业级 Java 应用对接主流 AI 工具链提供了可复用的解决方案。 当前 Spring AI 对 MCP 的封装存在兼容性问题(如空请求体导致的反序列化失败),需社区推动完善 SDK 实现,或依赖更灵活的原生库。尽管 MCP 协议本身语言无关,但各语言 SDK 的实现细节(如传输层、错误处理)仍需统一规范,以降低跨语言协作成本。- 性能优化:探索 WebSocket 替代 SSE 以支持双向实时通信,提升高并发场景下的效率。
- 安全增强:引入 TLS 加密、身份认证(OAuth/JWT)保障服务间通信安全。
- 可观测性:集成日志追踪(如 OpenTelemetry)与指标监控,提升系统运维能力。
MCP 的普及依赖于多语言工具链的丰富性。Java 社区可贡献更多开箱即用的 MCP 工具库,并推动与主流框架(如 Spring Cloud、Quarkus)的深度集成。MCP 的核心价值在于定义标准化接口,解耦模型与工具。对 Java 开发者而言,无需受限于 Python 主导的 MCP 生态,而应聚焦于协议本身的工程化落地。通过本文的实践,我们已证明:Java 同样可以成为 AI 工具链的“连接器”,为复杂业务场景提供稳定、高效的支持。 未来,随着 MCP 协议的演进与多语言 SDK 的成熟,跨生态协作将成为 AI 应用开发的常态。期待更多开发者加入这一探索,共同构建开放、兼容、可扩展的智能系统。 |