用 Spring Boot 写一个真正能跑的 MCP Server:接入 Open-Meteo 天气查询(TaoToken 统一 Key 配置版)
1. 从一次「连不上」的调试说起Spring Boot MCP Server 到底难在哪如果你最近在搜 Spring Boot、MCP Server、Open-Meteo、Spring AI、STDIO 这几个词大概率是想干一件事让本地跑起来的 Java 服务被 Claude Desktop、Cursor 或者 MCP Inspector 当成一个标准工具来调用。听起来不难但真正动手时很多人卡在第一步——服务明明启动了客户端却一直连不上或者连上了但工具列表是空的。我自己第一次搭的时候也踩了这个坑。JAR 包能跑日志看着一切正常可 Inspector 就是秒断。后来才发现问题出在 STDIO 模式下标准输出被 Spring 的启动日志污染了。MCP 的 JSON-RPC 消息走的就是 stdin/stdout你往里塞一行 Banner客户端解析就崩了。这篇就围绕这个真实场景来写用 Spring Boot 3.5 Spring AI 2.0 搭一个基于 STDIO 的 MCP Server接入免费的 Open-Meteo 天气 API暴露一个get_weather工具。整条链路不需要大模型 API Key也不需要申请天气服务密钥打包后就能通过 MCP Inspector 验证。适合谁适合已经会写 Spring Boot、想快速把 MCP 跑通、不想在概念上绕圈的 Java 开发者。链路其实只有一条MCP Client 通过 STDIO 发 JSON-RPC 请求 → Spring Boot MCP Server 收到调用 → 工具内部先调 Open-Meteo Geocoding API 把城市名转成经纬度 → 再调 Forecast API 拿实时天气 → 返回结构化结果。MCP 负责「怎么被发现和调用」Open-Meteo 负责「数据从哪来」两件事别混在一起。2. TaoToken 前置统一 Key 与 API 通道怎么接在讲代码之前先把 Key 和通道这件事说清楚。MCP Server 本身不强制依赖大模型但一旦你要把它接到 Claude Code、Cursor 这类客户端里或者后续想用统一的 API 通道管理模型调用就需要一个稳定的入口。我这边用的是 TaoToken 的统一 Key 方案官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它的作用是把模型调用和工具调用的入口收敛到一个 Key 上省得你在多个平台之间来回切换配置。对于这个天气 MCP Server 来说TaoToken 不是必需的——Open-Meteo 完全免费且无需鉴权——但如果你打算把这个 Server 接到一个真实的 Agent 工作流里统一 Key 会让配置干净很多。具体操作上你需要先去控制台创建一个 API Key。控制台入口在这里https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建完之后Key 的管理页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 可以随时查看和轮换。拿到 Key 之后客户端侧的配置通常写在一个settings.json或者类似的配置文件里。以 Claude Code 风格的配置为例片段大概长这样{ mcpServers: { weather-mcp-server: { command: java, args: [ -jar, /absolute/path/weather-mcp-server-0.0.1-SNAPSHOT.jar ], env: { TAOTOKEN_API_KEY: sk-你的统一Key, TAOTOKEN_BASE_URL: https://taotoken.net/api } } } }这里TAOTOKEN_API_KEY和TAOTOKEN_BASE_URL是给后续扩展用的。当前这个天气 Demo 不会真的去调大模型但把环境变量预留好等你以后想在这个 Server 里加一个「用模型总结天气」的工具就不用再改配置结构了。注意不要把 Key 硬编码进 Java 代码或者提交到 Git。用环境变量注入是最省事的做法Spring Boot 里直接Value(${TAOTOKEN_API_KEY:})就能读到。如果你只是想先验证模型对话通道是否正常可以走这个入口https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 。长期做编码和 Agent 的话Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Claude Code 相关的配置可以参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 。3. 可复制配置pom、application.yml 与 MCP 工具骨架3.1 Maven 依赖先看pom.xml。核心是引入 Spring AI BOM 和 MCP Server Starter。注意 STDIO 场景用的是spring-ai-starter-mcp-server不是 WebFlux 或 WebMVC 那个版本。properties 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这里有个细节我没有引入spring-boot-starter-web。因为 STDIO 模式下不需要 Web 容器少启动一个 Tomcat 能让边界更清晰也避免端口占用之类的干扰。spring-web只是为了用RestClient去调 Open-Meteo。3.2 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: OFFweb-application-type: none告诉 Spring 不要起 Web 容器。banner-mode: off关掉启动 Banner。logging.level.root: OFF把日志压到最低。这三条都是为了保护 stdout 的纯净度。为什么要这么狠因为 STDIO 模式下stdout 承载的是 MCP 的 JSON-RPC 消息。你往里混一行Started WeatherMcpServerApplication in 2.3 seconds客户端读到的就不是合法 JSON直接解析失败。我第一次调试时遇到的现象就是 Inspector 一直连接失败单独运行程序看起来毫无异常问题恰恰出在「正常输出」上。处理原则不要在 STDIO Server 里用System.out.println()日志写到 stderr 或文件工具返回值通过 MCP 框架返回不要自己打印。3.3 Open-Meteo 客户端Open-Meteo 的天气接口接收经纬度但用户只会说「杭州天气怎么样」。所以要先调 Geocoding API 把城市名转成坐标再调 Forecast API。先定义返回给 Agent 的结果对象public record WeatherResult( String city, String country, double latitude, double longitude, double temperature, double apparentTemperature, int humidity, double windSpeed, int weatherCode, String observedAt) { }然后是客户端封装。为了让你看清核心逻辑这里只保留必要字段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()); } }响应对象用 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也能隔离第三方接口变化。3.4 MCP 工具注册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这种过于宽泛的名称工具一多就很容易让模型选错。4. 验证请求打包、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输入「杭州」点击运行。如果一切正常你会看到类似这样的返回{ city: 杭州, country: 中国, latitude: 30.29365, longitude: 120.16142, temperature: 18.4, apparentTemperature: 17.9, humidity: 72, windSpeed: 8.3, weatherCode: 3, observedAt: 2025-01-15T14:00 }看到城市、温度、湿度和风速都出来了说明从 MCP 协议到外部天气 API 的整条链路已经跑通。这一步是整个 Demo 的关键验证点过了这里剩下的就是把它接到真实客户端里。如果你用的是 Claude Desktop 或 Cursor把第 2 节里的settings.json片段填进去重启客户端工具列表里就会出现get_weather。调用方式和 Inspector 里一样输入城市名即可。5. 本篇常见错排查5.1 Inspector 秒断或一直连不上这是最高频的问题九成以上是 stdout 被污染。检查三件事banner-mode是否设为offlogging.level.root是否设为OFF代码里有没有System.out.println()。Spring 的启动日志默认走 stdout在 STDIO 模式下必须关掉。5.2 工具列表为空如果 Inspector 能连上但 Tools 列表是空的先确认annotation-scanner.enabled是否为true再确认WeatherTools类上有没有Component。Spring AI 只扫描 Spring Bean 上的McpTool注解普通类不会被识别。5.3 城市名解析失败Open-Meteo 的 Geocoding API 对中文城市名支持还行但偶尔会有歧义。如果返回「没有找到城市」可以试试用拼音或者英文名。另外count参数设成 1 只取第一个结果如果第一个结果不准确可以调大这个值再手动筛选。5.4 天气接口超时Open-Meteo 是免费服务偶尔会有网络抖动。生产环境里应该给RestClient配上连接超时和读取超时比如RestClient restClient RestClient.builder() .requestFactory(new SimpleClientHttpRequestFactory() {{ setConnectTimeout(5000); setReadTimeout(10000); }}) .build();天气查询属于只读操作可以对网络抖动做少量重试。但如果以后换成「创建订单」「发送邮件」这类有副作用的工具就必须先考虑幂等不能无脑重试。5.5 返回体字段为 nullOpen-Meteo 的current字段在某些参数组合下可能返回空。检查queryParam(current, ...)里的字段名是否拼写正确比如temperature_2m不能写成temperature。字段名错了不会报错只会返回 null。6. 把这条链路接到你的工作流里天气 Demo 跑通之后你会发现 MCP Server 的骨架其实很通用一个 Spring Boot 应用一个McpTool注解的方法一个外部 API 客户端。换掉 Open-Meteo换成你自己的业务接口就是一个能用的 Agent 工具。如果你打算长期做编码和 Agent 相关的事情建议把 Key 和通道统一管理起来。API Key 的创建和管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 模型对话验证在 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 长期编码和 Agent 场景可以看 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。Claude Code 的配置参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 。最后留一个实用技巧调试 STDIO MCP Server 时把日志重定向到文件比如java -jar app.jar 2server.log这样既不影响协议通道又能保留排查线索。这个习惯能帮你省下不少「明明启动了却连不上」的时间。