用 Spring Boot 写一个真正能跑的 MCP Server:接入 Open-Meteo 天气查询

用 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 21Spring Boot 3.5.xSpring AI 2.0.0MavenMCP STDIOOpen-MeteoMCP InspectorSpring AI 当前为不同传输方式提供了不同的 MCP Server Starter。STDIO 场景使用spring-ai-starter-mcp-server并开启spring.ai.mcp.server.stdiotrue。2. 添加 Maven 依赖在pom.xml中引入 Spring AI BOM 和 MCP Server Starterproperties java.version21/java.version spring-ai.version2.0.0/spring-ai.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework/groupId artifactIdspring-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 APIhttps://geocoding-api.open-meteo.com/v1/search得到经纬度后再调用 Forecast APIhttps://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(ListGeoLocation 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 启动 JARnpx modelcontextprotocol/inspector \ java \ -jar \ /absolute/path/weather-mcp-server-0.0.1-SNAPSHOT.jarWindows 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 超时、返回体为空都不能直接变成一长串堆栈信息。实际项目中还应补充连接超时、读取超时、有限重试和统一错误结果。但重试也不能无限加。天气查询属于只读操作可以对网络抖动做少量重试如果以后换成“创建订单”“发送邮件”一类有副作用的工具就必须先考虑幂等。