最近 MCP 很火,但我看了不少教程后发现一个问题:很多文章花了大量篇幅解释 MCP 是什么,真正到 Java 工程里,却只留下几段零散代码。

所以这次我想做得直接一点:用 Spring Boot 和 Spring AI 搭建一个基于 STDIO 的 MCP Server,再接入免费的 Open-Meteo 天气 API。整个示例不需要大模型 API Key,也不需要申请天气服务密钥,打包后就能通过 MCP Inspector 调用。

最终效果很简单:MCP 客户端调用 get_weather 工具,输入城市名称,服务端先把城市解析成经纬度,再返回实时天气。

1. MCP Server 在这条链路中做了什么?

先不要急着写代码。这个 Demo 实际上只有一条调用链:

MCP Client
   ↓ STDIO / JSON-RPC
Spring Boot MCP Server
   ↓ 城市名称
Open-Meteo Geocoding API
   ↓ 经纬度
Open-Meteo Forecast API
   ↓
结构化天气结果

这里需要分清两件事:

  • MCP 负责规范客户端如何发现和调用工具;

  • Open-Meteo 才是真正提供天气数据的外部服务。

换句话说,我们不是把天气接口“改造成大模型”,而是把它包装成一个 Agent 可以理解和调用的标准工具。

本文使用:

  • Java 21

  • Spring Boot 3.5.x

  • Spring AI 2.0.0

  • Maven

  • MCP STDIO

  • Open-Meteo

  • MCP Inspector

Spring AI 当前为不同传输方式提供了不同的 MCP Server Starter。STDIO 场景使用 spring-ai-starter-mcp-server,并开启 spring.ai.mcp.server.stdio=true

2. 添加 Maven 依赖

pom.xml 中引入 Spring AI BOM 和 MCP Server Starter:

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
    </dependency>
</dependencies>

这个服务不需要提供 Web 接口,因此不必引入 spring-boot-starter-web。对一个本地 STDIO MCP Server 来说,少启动一个 Web 容器,也能让边界更清楚。

3. 配置 STDIO:这里藏着第一个坑

application.yml 可以这样写:

spring:
  application:
    name: weather-mcp-server
  main:
    web-application-type: none
    banner-mode: off
  ai:
    mcp:
      server:
        name: weather-mcp-server
        version: 1.0.0
        type: SYNC
        stdio: true
        annotation-scanner:
          enabled: true

logging:
  level:
    root: OFF

为什么要关闭控制台日志和 Banner?

因为在 STDIO 模式下,标准输入和标准输出不是普通控制台,它们承载的是 MCP 的 JSON-RPC 消息。如果 Spring Banner、System.out.println() 或日志混入 stdout,客户端读到的就不再是纯协议数据。

我第一次调试时遇到的现象就是:JAR 明明能启动,Inspector 却一直连接失败。单独运行程序看起来没有异常,问题恰恰出在“正常输出”的启动日志污染了协议通道。

处理原则很简单:

  • 不要在 STDIO Server 中使用 System.out.println()

  • 日志写到 stderr 或文件;

  • 关闭无必要的 Banner 和控制台输出;

  • 工具返回值通过 MCP 框架返回,不要自己打印。

这是本文最值得记住的坑。

4. 调用 Open-Meteo:先定位城市,再查天气

Open-Meteo 的天气接口接收经纬度,而用户通常只会说“杭州天气怎么样”。所以需要先调用 Geocoding API:

https://geocoding-api.open-meteo.com/v1/search

得到经纬度后,再调用 Forecast API:

https://api.open-meteo.com/v1/forecast

先定义最终返回给 Agent 的结果:

public record WeatherResult(
        String city,
        String country,
        double latitude,
        double longitude,
        double temperature,
        double apparentTemperature,
        int humidity,
        double windSpeed,
        int weatherCode,
        String observedAt) {
}

然后封装 Open-Meteo 客户端。为了让核心逻辑容易看懂,下面只保留必要字段:

@Service
public class OpenMeteoClient {

    private final RestClient restClient = RestClient.create();

    public WeatherResult getCurrentWeather(String city) {
        GeoResponse geo = restClient.get()
                .uri(uriBuilder -> uriBuilder
                        .scheme("https")
                        .host("geocoding-api.open-meteo.com")
                        .path("/v1/search")
                        .queryParam("name", city)
                        .queryParam("count", 1)
                        .queryParam("language", "zh")
                        .queryParam("format", "json")
                        .build())
                .retrieve()
                .body(GeoResponse.class);

        if (geo == null || geo.results() == null || geo.results().isEmpty()) {
            throw new IllegalArgumentException("没有找到城市:" + city);
        }

        GeoLocation location = geo.results().getFirst();

        ForecastResponse forecast = restClient.get()
                .uri(uriBuilder -> uriBuilder
                        .scheme("https")
                        .host("api.open-meteo.com")
                        .path("/v1/forecast")
                        .queryParam("latitude", location.latitude())
                        .queryParam("longitude", location.longitude())
                        .queryParam("current",
                                "temperature_2m,apparent_temperature," +
                                "relative_humidity_2m,weather_code,wind_speed_10m")
                        .queryParam("timezone", "auto")
                        .build())
                .retrieve()
                .body(ForecastResponse.class);

        if (forecast == null || forecast.current() == null) {
            throw new IllegalStateException("天气服务暂时没有返回有效数据");
        }

        CurrentWeather current = forecast.current();
        return new WeatherResult(
                location.name(),
                location.country(),
                location.latitude(),
                location.longitude(),
                current.temperature_2m(),
                current.apparent_temperature(),
                current.relative_humidity_2m(),
                current.wind_speed_10m(),
                current.weather_code(),
                current.time());
    }
}

响应对象可以用 Java Record 表达:

record GeoResponse(List<GeoLocation> results) {}

record GeoLocation(
        String name,
        String country,
        double latitude,
        double longitude) {}

record ForecastResponse(CurrentWeather current) {}

record CurrentWeather(
        String time,
        double temperature_2m,
        double apparent_temperature,
        int relative_humidity_2m,
        double wind_speed_10m,
        int weather_code) {}

这里我没有直接返回 Open-Meteo 的原始 JSON。外部接口字段经常很多,而 Agent 真正需要的是稳定、清晰、语义明确的工具结果。自己定义 DTO,也能隔离第三方接口变化。

5. 用 @McpTool 暴露天气工具

Spring AI 2.0 可以扫描 Spring Bean 上的 MCP 注解。工具类只需要这样写:

@Component
public class WeatherTools {

    private final OpenMeteoClient openMeteoClient;

    public WeatherTools(OpenMeteoClient openMeteoClient) {
        this.openMeteoClient = openMeteoClient;
    }

    @McpTool(
            name = "get_weather",
            description = "查询指定城市的实时天气,包括温度、体感温度、湿度和风速")
    public WeatherResult getWeather(
            @McpToolParam(
                    description = "城市名称,例如杭州、北京或 Chicago",
                    required = true)
            String city) {

        if (city == null || city.isBlank()) {
            throw new IllegalArgumentException("城市名称不能为空");
        }

        return openMeteoClient.getCurrentWeather(city.trim());
    }
}

这里的工具描述不是可有可无的注释。MCP 客户端会把工具名称、描述和参数 Schema 提供给模型,模型据此决定何时调用工具以及传什么参数。

因此,工具描述至少应回答三个问题:

  1. 这个工具能做什么?

  2. 什么时候应该调用?

  3. 参数应该采用什么格式?

queryexecute 这种过于宽泛的名称,在 Demo 中也许能跑,工具一多就很容易让模型选错。

6. 打包并使用 MCP Inspector 测试

先打包:

./mvnw clean package

然后通过 MCP Inspector 启动 JAR:

npx @modelcontextprotocol/inspector \
  java \
  -jar \
  /absolute/path/weather-mcp-server-0.0.1-SNAPSHOT.jar

Windows PowerShell 可以写成一行:

npx @modelcontextprotocol/inspector java -jar D:\project\weather-mcp-server\target\weather-mcp-server-0.0.1-SNAPSHOT.jar

打开 Inspector 后:

  1. 连接类型选择 STDIO;

  2. 查看 Tools 列表;

  3. 选择 get_weather

  4. 输入 杭州

  5. 点击运行。

如果能看到城市、温度、湿度和风速,说明从 MCP 协议到外部天气 API 的整条链路已经跑通。

7. 我在这个 Demo 中踩到的几个坑

坑一:把普通 Spring Boot 日志写进 stdout

这是 STDIO 场景最隐蔽的问题。服务能启动,不代表协议通道正常。出现 Inspector 秒断、解析错误或一直连不上时,先检查标准输出。

坑二:直接让模型传经纬度

从接口角度看,经纬度最直接;从工具设计角度看,却把底层细节推给了模型和用户。把“城市转坐标”封装在工具内部,调用体验明显更自然。

坑三:工具描述写得太随意

工具能否被正确选择,不只取决于 Java 方法实现,也取决于名称、描述和参数 Schema。工具数量增加后,这一点尤其明显。

坑四:只考虑正常响应

城市不存在、Open-Meteo 超时、返回体为空,都不能直接变成一长串堆栈信息。实际项目中还应补充连接超时、读取超时、有限重试和统一错误结果。

但重试也不能无限加。天气查询属于只读操作,可以对网络抖动做少量重试;如果以后换成“创建订单”“发送邮件”一类有副作用的工具,就必须先考虑幂等。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐