关于MCP的简介

Spring AI支持MCP的模型

有一些模型不支持mcp:ollama中的gemma和deepseek

现在我使用的支持的:ollama中的qwen3, gpt, openai(deepseek)

Server和Client

MCP的关键是构建server和client

server的作用是提供发送http请求和接收响应的工具

client的作用是创建chatclient,与用户进行交互,然后调用工具

MCP和Function Calling的区别

个人的理解是,function calling就是在一台服务器上进行的,大模型可以根据用户的输入选择本地的工具进行调用

MCP中,client只负责对chatclient进行规定,mcp把server上的tool注册给大模型使用,tool是在另一个服务器上的

项目结构

首先是项目的结构

📁 mcp-project (根项目 - Maven 多模块项目)
├── 📄 pom.xml (父 POM: 定义模块、公共依赖 spring-boot-starter-web, lombok, jackson-databind, spring-ai-bom)

├── 📁 mcp-client (模块 - 天气查询客户端)
│   ├── 📄 pom.xml (依赖: spring-ai-starter-mcp-client, spring-ai-starter-model-*, mybatis-plus, mysql-connector-j)
│   └── 📁 src/main/java/com/hyk/mcpclient
│       ├── 📄 McpClientApplication.java (Spring Boot 启动类, @MapperScan)
│       ├── 📁 client
│       │   └── 📄 WeatherClientConfig.java (配置 ChatClient Bean, 使用 OpenAI 模型)
│       ├── 📁 common
│       │   └── 📄 prompt.java (定义系统提示词 PROMPT_WEATHER)
│       ├── 📁 controller
│       │   └── 📄 WeatherController.java (REST 控制器, /ai/weather 接口)
│       ├── 📁 domain/pojo
│       │   └── 📄 City.java (MyBatis Plus 实体类)
│       ├── 📁 mapper
│       │   └── 📄 CityMapper.java (MyBatis Plus Mapper)
│       ├── 📁 service
│       │   ├── 📄 CityService.java (接口)
│       │   └── 📁 Impl
│       │       └── 📄 CityServiceImpl.java (实现类)
│       └── 📁 tool
│           └── 📄 GetCityAdcodeTool.java (@Tool 注解, 调用 CityService)
│       └── 📁 src/main/resources
│           ├── 📄 application.yaml (配置: OpenAI, MCP Client SSE 连接, 数据源)
│           ├── 📄 flux.html (用于测试 SSE 流式输出的 HTML 页面)
│           └── 📄 mcp-servers-config.json (MCP 服务器进程配置)

├── 📁 mcp-server (模块 - 提供天气查询工具的 MCP 服务器)
│   ├── 📄 pom.xml (依赖: spring-ai-starter-mcp-server-webmvc)
│   └── 📁 src/main/java/com/hyk/mcpserver
│       ├── 📄 McpServerApplication.java (Spring Boot 启动类)
│       ├── 📁 config
│       │   ├── 📄 RestTemplateConfig.java (配置 RestTemplate Bean)
│       │   └── 📄 ToolCallbackProviderConfig.java (注册 WeatherService 工具)
│       └── 📁 service
│           ├── 📄 WeatherService.java (接口)
│           └── 📁 Impl
│               └── 📄 WeatherServiceImpl.java (@Tool 注解实现, 调用高德天气 API)
│       └── 📁 src/main/resources
│           └── 📄 application.yaml (配置: server port 8080, MCP server name, weather.url)
│       └── 📁 src/test/java/com/hyk/mcpserver
│           └── 📄 WeatherTest.java (测试高德天气 API 调用)

└── 📁 mcp-tools (模块 - 工具模块,例如数据导入)
    ├── 📄 pom.xml (依赖: spring-boot-starter-data-jpa, spring-boot-starter-web, poi-ooxml)
    └── 📁 src/main/java/com/hyk/mcptools
        ├── 📄 McpToolsApplication.java (Spring Boot 启动类)
        ├── 📁 pojo
        │   └── 📄 City.java (JPA 实体类)
        ├── 📁 repo
        │   └── 📄 CityRepository.java (Spring Data JPA Repository)
        └── 📁 service
            └── 📄 LocalFileImportService.java (从 Excel 导入城市数据)
    └── 📁 src/main/resources
        └── 📄 application.yaml (配置: 数据源, JPA)
    └── 📁 src/test/java/com/hyk/mcptools
        └── 📄 ImportCitiesTest.java (测试城市数据导入)
 

现在我只完成了使用高德地图的api查询指定城市天气的功能,后序还将添加更多功能完善

引入的共同依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

    </dependencies>

    <!-- 依赖管理 -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>1.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

jackson用来对json进行处理

Server模块

Server模块定义的是向高德网站发送请求这一步的代码

依赖和配置

依赖:

<dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>
    </dependencies>

这个是作为mcp server的必要依赖

配置文件:

server:
  port: 8080
spring:
  ai:
    mcp:
      server:
        name: weather-server
        sse-message-endpoint: /mcp/weather

weather:
  url: https://restapi.amap.com/v3/weather/weatherInfo

server和client使用的是sse通信,也有http通信

在配置项中设置了sse-message-endpoint,这一步让我们可以不用去写controller,它代替了controller的作用直接可以去调用WeatherService

具体实现类

ToolCallbackProviderConfig

作用是作为配置类,定义tools

@Configuration
public class ToolCallbackProviderConfig {
    @Bean
    public ToolCallbackProvider WeatherTools(WeatherService weatherService){
        return MethodToolCallbackProvider.builder()
                .toolObjects(weatherService)
                .build();
    }
}

这样我们就将weatherService作为一个tool了

WeatherService和impl

package com.hyk.mcpserver.service;

import com.fasterxml.jackson.core.JsonProcessingException;

public interface WeatherService {
    String getWeather() throws JsonProcessingException;
    String getWeatherByCityAdcode(String city) throws JsonProcessingException;
}
package com.hyk.mcpserver.service.Impl;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hyk.mcpserver.service.WeatherService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Map;

@Service
@Slf4j
public class WeatherServiceImpl implements WeatherService {
    @Resource
    private RestTemplate restTemplate;

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Value("${weather.url}")
    String url;
    @Tool(description = "获取本地的天气信息")
    @Override
    public String getWeather() throws JsonProcessingException {
        String key = "04c55a94d23ed89125256f13407fe339"; // 使用有效的key
        String city = "510100";

        // 只使用必需参数
        String url = "https://restapi.amap.com/v3/weather/weatherInfo" +
                "?key=" + key +
                "&city=" + city;  // 移除了 extensions 和 output

        System.out.println("测试URL: " + url);

        ResponseEntity<String> response = restTemplate.exchange(
                url, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class
        );

        String body = response.getBody();
        Map<String, String> map = objectMapper.readValue(body, new TypeReference<Map<String, String>>() {
        });
        return map.get("province") + " " + map.get("city")
                + "今日的天气为" + map.get("weather")
                + ",温度:" + map.get("temperature") + "℃"
                + "湿度:" + map.get("humidity");
    }

    @Tool(description = "获取指定城市Adcode的天气信息")
    @Override
    public String getWeatherByCityAdcode(@ToolParam(description = "城市Adcode") String adcode) throws JsonProcessingException {
        String key = "04c55a94d23ed89125256f13407fe339";

        String url = "https://restapi.amap.com/v3/weather/weatherInfo" +
                "?key=" + key +
                "&city=" + adcode;

        System.out.println("测试URL: " + url);

        ResponseEntity<String> response = restTemplate.exchange(
                url, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class
        );

        String body = response.getBody();

        // 使用 Map<String, Object> 而不是 Map<String, String>
        Map<String, Object> map = objectMapper.readValue(body, new TypeReference<Map<String, Object>>() {});

        // 先检查API调用状态
        String status = (String) map.get("status");
        if (!"1".equals(status)) {
            System.out.println("API调用失败: " + map.get("info"));
            return "未找到天气数据";
        }

        // 获取 lives 数组
        @SuppressWarnings("unchecked")
        List<Map<String, String>> lives = (List<Map<String, String>>) map.get("lives");

        if (lives != null && !lives.isEmpty()) {
            Map<String, String> weatherData = lives.get(0); // 取第一个城市的天气数据

            String province = (String) weatherData.get("province");
            String cityName = (String) weatherData.get("city");
            String weather = (String) weatherData.get("weather");
            String temperature = (String) weatherData.get("temperature");
            String humidity = (String) weatherData.get("humidity");

            System.out.println(province + " " + cityName
                    + " 今日的天气为 " + weather
                    + ", 温度:" + temperature + "℃"
                    + ", 湿度:" + humidity + "%");
            return province + " " + cityName
                    + " 今日的天气为 " + weather
                    + ", 温度:" + temperature + "℃"
                    + ", 湿度:" + humidity + "%";
        } else {
            return "未找到天气数据";
        }
    }
}

获取本地的天气信息这个方法是有问题的,我没有直接改,在处理返回参数的时候解析的逻辑是不正确的,正确的参考根据Adcode获取天气信息的方法

其中重要的是两个annotation

@Tool:把这个方法标记为tool,并进行定义

@ToolParam:定义这个tool方法的传入参数的意义是什么,向ai解释

然后Server就差不多完成了

Client模块

依赖和配置

依赖:

<dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-client</artifactId>
        </dependency>

        <!-- 使用的大模型依赖 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.7</version>
        </dependency>
        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.33</version>
            <scope>runtime</scope>
        </dependency>

    </dependencies>

之所以要mysql是因为我们需要去查询数据库得到用户输入城市的Adcode,然后在让大模型拿着Adcode去调用server的tool

配置文件:

server:
  port: 8081

spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        model: qwen3:14b
    openai:
      enabled: true
      base-url: https://api.deepseek.com
      api-key: sk-xxxxxx
      chat:
        options:
          model: deepseek-chat

    mcp:
      client:
        enabled: true
        stdio:
          enabled: false  # 禁用 STDIO
        sse:
          connections:
            server1:
              url: http://localhost:8080/mcp/weather

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://xxxxxxx:3306/weather_city?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true
    username: xxxx
    password: xxxxx
    # 连接池配置(可选)
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

logging:
  level:
    org.springframework.ai: DEBUG  # Spring AI 相关日志
    org.springframework.ai.chat.client.advisor: DEBUG  # Advisor 日志
    root: INFO
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"

这里需要指定sse连接的server的url

具体实现类

定义Client的config类

package com.hyk.mcpclient.client;

import com.hyk.mcpclient.common.prompt;
import com.hyk.mcpclient.tool.GetCityAdcodeTool;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WeatherClientConfig {
    @Resource
    private OllamaChatModel ollamaChatModel;

    @Resource
    private OpenAiChatModel openAiChatModel;

    @Resource
    private ToolCallbackProvider toolCallbackProvider;

    @Resource
    private GetCityAdcodeTool getCityAdcodeTool;
    @Bean
    public ChatClient weatherClient() {
        return ChatClient.builder(openAiChatModel)
                .defaultSystem(prompt.PROMPT_WEATHER)
                .defaultAdvisors(new SimpleLoggerAdvisor())
                .defaultTools(getCityAdcodeTool)
                .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks())
                .build();
    }
}

可以选ollama qwen3(本地)和openai(deepseek),本地的会慢很多

prompt提示词

package com.hyk.mcpclient.common;

public class prompt {
    public static final String PROMPT_WEATHER = """
                你是一个专业的天气查询助手。请遵循以下规则:
                
                查询流程:
                1. 首先使用 getCityAdcode 工具确认城市的adcode
                2. 然后使用 getWeatherByAdcode 工具查询具体天气
                3. 如果城市不存在,直接告知用户
                
                回复要求:
                - 用友好的中文回复
                - 包含完整的天气信息
                - 如果查询失败,给出友好的错误提示
                
                示例回复:
                "北京当前天气:晴,温度25°C,湿度60%,东南风3级,祝您有愉快的一天!🌞"
                
                如果查询失败,请告诉我失败的具体原因
                """;
}

Controller

package com.hyk.mcpclient.controller;

import com.hyk.mcpclient.client.WeatherClientConfig;
import com.hyk.mcpclient.tool.GetCityAdcodeTool;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
@Slf4j
public class WeatherController {

    @Resource
    private ChatClient weatherClient;

    @GetMapping("/ai/weather")
    public String generateCityWeather(@RequestParam String city) {
        return weatherClient.prompt()
                .user("帮我查询" + city + "的天气")
                .call()
                .content();
    }

    @GetMapping(value = "/ai/weatherFlux", produces = "text/html; charset=utf-8")
    public Flux<String> generateCityWeatherByFlux(@RequestParam String city) {
        return weatherClient.prompt()
                .user("帮我查询" + city + "的天气")
                .stream()
                .content();
    }


}

Service

package com.hyk.mcpclient.service;

public interface CityService {
    public String getCityAdcodeByName(String chineseName);
}

ServiceImpl

package com.hyk.mcpclient.service.Impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hyk.mcpclient.domain.pojo.City;
import com.hyk.mcpclient.mapper.CityMapper;
import com.hyk.mcpclient.service.CityService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class CityServiceImpl extends ServiceImpl<CityMapper, City> implements CityService {
    @Override
    public String getCityAdcodeByName(String chineseName) {
        log.info("根据中文名查询Adcode: {}", chineseName);
        City city = lambdaQuery()
                .like(City::getChineseName, chineseName)  // 模糊查询
                .select(City::getAdcode)
                .one();
        return city != null ? city.getAdcode() : null;
    }
}

Mapper

package com.hyk.mcpclient.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hyk.mcpclient.domain.pojo.City;


public interface CityMapper extends BaseMapper<City> {

}

我这里使用的MB Plus的mysql操作

mapper的指定在启动项添加@MapperScanner

Pojo类

package com.hyk.mcpclient.domain.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("city")  // MyBatis Plus 的表名注解
public class City {

    @TableId(type = IdType.AUTO)  // MyBatis Plus 的主键注解
    private Integer id;

    @TableField("chinese_name")  // MyBatis Plus 的字段注解
    private String chineseName;

    private String adcode;

    private String citycode;

    // 自定义构造方法
    public City(String chineseName, String adcode, String citycode) {
        this.chineseName = chineseName;
        this.adcode = adcode;
        this.citycode = citycode;
    }
}

整个项目的运行结果:

额外:

添加了mcp工具后使用本地的ollama qwen3不知道为什么不能流式输出了,感觉是springAi的bug

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐