SpringAI实践(三)

418 阅读3分钟

一、什么是MCP

模型上下文协议(MCP)是一种标准化协议,使 AI 模型能够以结构化的方式与外部工具和资源进行交互。它支持多种传输机制,以在不同环境中提供灵活的使用方式。

二、MCP传输层协议

stdio传输层

stdio(标准输入输出)传输层是MCP最基本的传输实现方式。它通过进程间通信(IPC)实现,具体工作原理如下:

  1. 进程创建:MCP客户端会启动一个子进程来运行MCP服务器
  2. 通信机制
    • 使用标准输入(stdin)向MCP服务器发送请求
    • 通过标准输出(stdout)接收MCP服务器的响应
    • 标准错误(stderr)用于日志和错误信息
  3. 优点
    • 简单可靠,无需网络配置
    • 适合本地部署场景
    • 进程隔离,安全性好
  4. 缺点
    • 仅支持单机部署
    • 不支持跨网络访问
    • 每个客户端需要独立启动服务器进程

SSE传输层

SSE(Server-Sent Events)传输层是基于HTTP的单向通信机制,专门用于服务器向客户端推送数据。其工作原理如下:

  1. 连接建立
    • 客户端通过HTTP建立与服务器的持久连接
    • 使用text/event-stream内容类型
  2. 通信机制
    • 服务器可以主动向客户端推送消息
    • 支持自动重连机制
    • 支持事件ID和自定义事件类型
  3. 优点
    • 支持分布式部署
    • 可跨网络访问
    • 支持多客户端连接
    • 轻量级,使用标准HTTP协议
  4. 缺点
    • 需要额外的网络配置
    • 相比stdio实现略微复杂
    • 需要考虑网络安全性

三、使用Spring AI编写MCP服务端

Studio协议

新建一个Spring MVC的工程,引入依赖

<!-- Spring Web -->  
<spring-web.version>6.2.0</spring-web.version>

<dependency>  
    <groupId>org.springframework</groupId>  
    <artifactId>spring-web</artifactId>  
    <version>${spring-web.version}</version>  
</dependency>  
  
<dependency>  
    <groupId>org.springframework.ai</groupId>  
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>  
</dependency>

新增配置文件


spring.main.web-application-type=none  
  
# NOTE: You must disable the banner and the console logging  
# to allow the STDIO transport to work !!!  
spring.main.banner-mode=off  
logging.pattern.console=  
  
spring.ai.mcp.server.name=mcp-server  
spring.ai.mcp.server.version=0.0.1  
logging.file.name=D:/Projects/IdeaProjects/spring-ai-learn/mcp-server/target/mcp-stdio-server.log

新建一个Service,编写MCP能提供的服务,例子里面调用了网络的的今日简报、微博热搜的接口,提供查询新闻热点的能力


package com.renne.ai.learn.mcpserver.service;  
  
import com.alibaba.fastjson2.JSONArray;  
import com.alibaba.fastjson2.JSONObject;  
import org.slf4j.Logger;  
import org.springframework.ai.tool.annotation.Tool;  
import org.springframework.stereotype.Service;  
import org.springframework.web.client.RestClient;  
  
  
/**  
 * @author LiuYu  
 * @since 2025-03-27 18:42  
 */@Service  
public class McpToolsService {  
  
    private static final Logger logger = org.slf4j.LoggerFactory.getLogger(McpToolsService.class);  
  
    private static final String BASE_URL = "https://whyta.cn/api";  
  
    private final RestClient restClient;  
  
    public McpToolsService() {  
        this.restClient = RestClient.builder()  
                .baseUrl(BASE_URL)  
                .defaultHeader("Accept", "application/json")  
                .defaultHeader("User-Agent", "OpenMeteoClient/1.0")  
                .build();  
    }  
  
    /**  
     * 获取今日简报  
     * 此方法调用外部API获取今日新闻,并返回新闻内容的字符串表示  
     *  
     * @return 今日新闻的字符串表示  
     */  
    @Tool(description = "获取今日榜单数据")  
    public String getTodayBulletin() {  
        try {  
            String news = restClient.get()  
                    .uri("/tx/bulletin?key=36de5db81215")  
                    .retrieve()  
                    .body(String.class);  
            return news != null ? formatBulletin(news) : "No news available today.";  
        } catch (Exception e) {  
            return "Failed to fetch news: " + e.getMessage();  
        }  
    }  
    /**  
     * 获取今日微博热搜  
     * 此方法调用外部API获取今日微博热搜,并返回新闻内容的字符串表示  
     *  
     * @return 今日新闻的字符串表示  
     */  
    @Tool(description = "获取微博热搜数据")  
    public String getTodayWeiboHot() {  
        try {  
            String news = restClient.get()  
                    .uri("/tx/weibohot?key=36de5db81215")  
                    .retrieve()  
                    .body(String.class);  
            return news != null ? formatWeiboHot(news) : "No news available today.";  
        } catch (Exception e) {  
            return "Failed to fetch news: " + e.getMessage();  
        }  
    }  
  
    /**  
     * 将数据格式化为指定的简报格式  
     *  
     * @param newsData 热搜数据  
     * @return 格式化后的字符串  
     */  
    public String formatWeiboHot(String newsData) {  
        JSONObject jsonObject = JSONObject.parseObject(newsData);  
        JSONArray newsList = jsonObject.getJSONObject("result").getJSONArray("list");  
  
        StringBuilder bulletin = new StringBuilder("以下是今日微博热搜:\n");  
        bulletin.append("---------------------------------\n");  
        for (int i = 0; i < newsList.size(); i++) {  
            JSONObject news = newsList.getJSONObject(i);  
            bulletin.append(" - 热搜话题:").append(news.getString("hotword")).append("\n");  
            bulletin.append(" - 热搜指数:").append(news.getString("hotwordnum")).append("\n");  
            bulletin.append(" - 热搜标签:").append(news.getString("hottag")).append("\n");  
            if (i < newsList.size() - 1) {  
                bulletin.append("---------------------------------\n");  
            }  
        }  
  
        return bulletin.toString();  
    }  
  
    /**  
     * 将新闻数据格式化为指定的简报格式  
     *  
     * @param newsData 新闻数据,包含新闻列表  
     * @return 格式化后的简报字符串  
     */  
    public String formatBulletin(String newsData) {  
        JSONObject jsonObject = JSONObject.parseObject(newsData);  
        JSONArray newsList = jsonObject.getJSONObject("result").getJSONArray("list");  
  
        StringBuilder bulletin = new StringBuilder("以下是今日简报:\n");  
        bulletin.append("---------------------------------\n");  
        for (int i = 0; i < newsList.size(); i++) {  
            JSONObject news = newsList.getJSONObject(i);  
            bulletin.append(" - 新闻标题:").append(news.getString("title")).append("\n");  
            bulletin.append(" - 新闻时间:").append(news.getString("mtime")).append("\n");  
            bulletin.append(" - 简报内容:").append(news.getString("digest")).append("\n");  
            if (i < newsList.size() - 1) {  
                bulletin.append("---------------------------------\n");  
            }  
        }  
  
        return bulletin.toString();  
    }  
  
}

增加配置项,将编写的Service放入到MethodToolCallbackProvider的toolObjects中

package com.renne.ai.learn.mcpserver.config;  
  
  
import com.renne.ai.learn.mcpserver.service.McpToolsService;  
import org.springframework.ai.tool.ToolCallbackProvider;  
import org.springframework.ai.tool.method.MethodToolCallbackProvider;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
  
/**  
 * @author LiuYu  
 * @since 2025-03-28 17:43  
 */@Configuration  
public class ToolCallbackConfig {  
  
    @Bean  
    public ToolCallbackProvider mcpTools(McpToolsService mcpToolsService) {  
        return MethodToolCallbackProvider.builder().toolObjects(mcpToolsService).build();  
    }  
}

编译打包,得到一个jar包,这样我们完成了一个MCP的客户端,用来提供新闻热点的服务。 Cursor最近增加MCP Servers可以支持添加MCP客户端,我们将我们自己写的放入提供给Cursor调用一下; 在mcp.json中加入执行jar的命令


{
    "mcpServers": {
        "mcp-server": {
            "command": "cmd",
            "args": [
                "/c",
                "java",
                "-Dspring.ai.mcp.server.stdio=true",
                "-Dspring.main.web-application-type=none",
                "-Dlogging.pattern.console=",
                "-jar",
                "D:\\Projects\\IdeaProjects\\spring-ai-learn\\mcp-server\\target\\mcp-server-0.0.1-SNAPSHOT.jar"
            ],
            "env": {}
        }
    }
}

这里就可以看到我们的服务已经可用了

Pasted image 20250328175645.png

我们来调用一下,向Cursor提问今天有哪些新闻,这里可以看到,他调用了我们微博热点和今日简报的两个方法,进行总结之后进行了回答

Pasted image 20250328175743.png

在我们自己的Spring AI MCP的客户端中使用MCP,首先引入依赖

<!-- Spring AI -->  
<spring-ai.version>1.0.0-M6</spring-ai.version>

<dependency>  
    <groupId>org.springframework.ai</groupId>  
    <artifactId>spring-ai-mcp-client-spring-boot-starter</artifactId>  
    <version>${spring-ai.version}</version>  
</dependency>

在配置文件中增加mcp的相关配置

spring:
	ai:
	    mcp:  
	      client:  
	        stdio:  
	          servers-configuration: classpath:/mcp-servers-config.json  
	          # 直接配置  
	#           connections:  
	#             server1:  
	#               command: java  
	#               args:  
	#                 - -jar  
	#                 - D:\Projects\IdeaProjects\spring-ai-learn\mcp-server\target\mcp-server-0.0.1-SNAPSHOT.jar  # 放一个绝对路径,修改为server jar包所在位置

增加配置文件中mcp-servers-config.json


{  
    "mcpServers": {  
        "weather": {  
            "command": "java",  
            "args": [  
                "-Dspring.ai.mcp.server.stdio=true",  
                "-Dspring.main.web-application-type=none",  
                "-Dlogging.pattern.console=",  
                "-jar",  
                "D:\\Projects\\IdeaProjects\\spring-ai-learn\\mcp-server\\target\\mcp-server-0.0.1-SNAPSHOT.jar"  
            ],  
            "env": {}  
        }  
    }  
}

编写Controller调用,模型依然是前一期中使用硅基流动的7B的千问模型,相关配置可以去看前一期


package com.renne.ai.learn.mcp.controller;  
  
import io.modelcontextprotocol.client.McpSyncClient;  
import org.springframework.ai.chat.client.ChatClient;  
import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor;  
import org.springframework.ai.chat.model.ChatModel;  
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.web.bind.annotation.GetMapping;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RequestParam;  
import org.springframework.web.bind.annotation.RestController;  
  
import java.util.List;  
  
/**  
 * @author LiuYu  
 * @since 2025-03-27 11:46  
 */@RestController  
@RequestMapping("/openai/mcp")  
public class McpController {  
  
    private final ChatModel chatModel;  
    private final List<McpSyncClient> mcpSyncClients;  
  
    public McpController(ChatModel chatModel, List<McpSyncClient> mcpSyncClients) {  
        this.chatModel = chatModel;  
        this.mcpSyncClients = mcpSyncClients;  
    }  
  
    /**  
     * 调用mcp对话  
     * 该方法接收一个可选的查询参数prompt和一个chatId参数,然后使用这些参数通过OpenAI聊天客户端生成响应内容  
     *  
     * @param message 查询参数,用于提示聊天内容,可为空  
     * @param chatId  聊天会话ID,用于关联聊天记录  
     * @return 返回由OpenAI聊天客户端生成的响应内容  
     */  
    @GetMapping("/chat")  
    public String get(@RequestParam(value = "message", required = false) String message, String chatId) {  
  
  
        // 使用OpenAI聊天客户端处理提示和聊天记录,并调用工具生成最终的响应内容  
        return ChatClient.create(chatModel)  
                .prompt(message) // 设置聊天提示内容  
                .advisors(a -> a  
                        .param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) // 设置聊天会话ID  
                        .param(AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100)) // 设置聊天记录检索数量  
                .tools(new SyncMcpToolCallbackProvider(mcpSyncClients))  
                .call() // 调用OpenAI聊天客户端生成响应  
                .content(); // 获取生成的响应内容  
    }  
  
  
}

我们来调用一下,可以看到模型也调用我们MCP的服务成功了,根据微博热点信息进行总结返回了

Pasted image 20250328175041.png

SSE协议

之后会补充