用 Spring Boot 写一个真正能跑的 MCP Server:接入 Open-Meteo 天气查询
最近 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 提供给模型,模型据此决定何时调用工具以及传什么参数。
因此,工具描述至少应回答三个问题:
-
这个工具能做什么?
-
什么时候应该调用?
-
参数应该采用什么格式?
像 query、execute 这种过于宽泛的名称,在 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 后:
-
连接类型选择 STDIO;
-
查看 Tools 列表;
-
选择
get_weather; -
输入
杭州; -
点击运行。
如果能看到城市、温度、湿度和风速,说明从 MCP 协议到外部天气 API 的整条链路已经跑通。
7. 我在这个 Demo 中踩到的几个坑
坑一:把普通 Spring Boot 日志写进 stdout
这是 STDIO 场景最隐蔽的问题。服务能启动,不代表协议通道正常。出现 Inspector 秒断、解析错误或一直连不上时,先检查标准输出。
坑二:直接让模型传经纬度
从接口角度看,经纬度最直接;从工具设计角度看,却把底层细节推给了模型和用户。把“城市转坐标”封装在工具内部,调用体验明显更自然。
坑三:工具描述写得太随意
工具能否被正确选择,不只取决于 Java 方法实现,也取决于名称、描述和参数 Schema。工具数量增加后,这一点尤其明显。
坑四:只考虑正常响应
城市不存在、Open-Meteo 超时、返回体为空,都不能直接变成一长串堆栈信息。实际项目中还应补充连接超时、读取超时、有限重试和统一错误结果。
但重试也不能无限加。天气查询属于只读操作,可以对网络抖动做少量重试;如果以后换成“创建订单”“发送邮件”一类有副作用的工具,就必须先考虑幂等。
更多推荐


所有评论(0)