springboot 版本: 3.5.4
cherry studio版本:1.4.7
通义灵码版本: 2.5.13
文章目录
问题描述:
基于Spring AI 开发本地天气 mcp server,该mcp server 采用stdio模式与MCP client 通信,本地cherry studio工具测试连接报错,通义灵码测试MCP server连接极易不稳定
引用依赖如下:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0-M7</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
<version>1.0.0-M7</version>
</dependency>
application.yml 配置如下:
项目打包后,
1. 通义灵码添加mcp server ,配置测试
mcp server 配置信息如下:
测试连接效果如下:
demo111 – pom.xml (demo111) 2025-07-05 11-19-32
2. cherry studio工具添加mcp server ,配置测试
配置信息如下:
测试连接效果如下:
项目源代码:
①天气服务
package com.example.demo111.service;
import com.example.demo111.model.CurrentCondition;
import com.example.demo111.model.WeatherResponse;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import javax.net.ssl.SSLException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Service
public class WeatherService1 {
private static final String BASE_URL = "https://wttr.in";
private final RestClient restClient;
public WeatherService1() {
this.restClient = RestClient.builder()
.baseUrl(BASE_URL)
.defaultHeader("Accept", "application/geo+json")
.defaultHeader("User-Agent", "WeatherApiClient/1.0 (your@email.com)")
.build();
}
@Tool(description = "Get current weather information for a China city. Input is city name (e.g. 杭州, 上海)")
public String getWeather(String cityName) {
WeatherResponse response = restClient.get()
.uri("/{city_name}?format=j1", cityName)
.retrieve()
.body(WeatherResponse.class);
if (response != null && response.getCurrent_condition() != null && !response.getCurrent_condition().isEmpty()) {
CurrentCondition currentCondition = response.getCurrent_condition().get(0);
String result = String.format("""
城市: %s
天气情况: %s
气压: %s(mb)
温度: %s°C (Feels like: %s°C)
湿度: %s%%
降水量:%s (mm)
风速: %s km/h (%s)
能见度: %s 公里
紫外线指数: %s
观测时间: %s
""",
cityName,
currentCondition.getWeatherDesc().get(0).getValue(),
currentCondition.getPressure(),
currentCondition.getTemp_C(),
currentCondition.getFeelsLikeC(),
currentCondition.getHumidity(),
currentCondition.getPrecipMM(),
currentCondition.getWindspeedKmph(),
currentCondition.getWinddir16Point(),
currentCondition.getVisibility(),
currentCondition.getUvIndex(),
currentCondition.getLocalObsDateTime()
);
return result;
} else {
return "无法获取天气信息,请检查城市名称是否正确或稍后重试。";
}
}
}
②数据模型
@Data
public class CurrentCondition {
private String feelsLikeC;
private String humidity;
private String localObsDateTime;
private String precipMM;
private String pressure;
private String temp_C;
private String uvIndex;
private String visibility;
private List<WeatherDesc> weatherDesc;
private String winddir16Point;
private String windspeedKmph;
}
@Data
public class WeatherDesc {
private String value;
}
@Data
public class WeatherResponse {
private List<CurrentCondition> current_condition;
}
③MCP SERVER配置
@Configuration
public class McpConfig {
//@Tool注解的方法注册为可供 LLM 调用的工具
@Bean
public ToolCallbackProvider weatherTools(WeatherService1 weatherService) {
return MethodToolCallbackProvider.builder().toolObjects(weatherService).build();
}
}
解决方案:
1. 项目改造
将基于Tomcat实现mcp server换成基于netty实现
注释或排除Tomcat依赖,引入netty依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>
天气服务重新基于netty改造实现
@Service
public class WeatherService {
private static final Logger logger = LoggerFactory.getLogger(WeatherService.class);
private static final String BASE_URL = "https://wttr.in";
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final int HTTP_TIMEOUT_SECONDS = 10;
private static final int MAX_RESPONSE_SIZE = 1024 * 1024; // 1MB
private final SslContext sslContext;
public WeatherService() throws SSLException {
this.sslContext = SslContextBuilder.forClient().build();
}
@Tool(description = "Get current weather information for a China city. Input is city name (e.g. 杭州, 上海)")
public String syncGetWeather(String cityName) {
try {
return getWeather(cityName).join();
} catch (Exception e) {
logger.error("Failed to get weather for city: {}", cityName, e);
return "获取天气信息失败,请稍后重试或检查城市名称是否正确。";
}
}
public CompletableFuture<String> getWeather(String cityName) {
CompletableFuture<String> future = new CompletableFuture<>();
if (cityName == null || cityName.trim().isEmpty()) {
future.completeExceptionally(new IllegalArgumentException("城市名称不能为空"));
return future;
}
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = configureBootstrap(group, future);
URI uri = buildWeatherUri(cityName);
ChannelFuture channelFuture = bootstrap.connect(uri.getHost(), 443);
channelFuture.addListener((ChannelFutureListener) f -> {
if (f.isSuccess()) {
sendWeatherRequest(f.channel(), uri);
} else {
handleConnectionFailure(future, f.cause(), group);
}
});
} catch (Exception e) {
handleInitializationError(future, e, group);
}
return future;
}
private Bootstrap configureBootstrap(EventLoopGroup group, CompletableFuture<String> future) {
return new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(sslContext.newHandler(ch.alloc()));
pipeline.addLast(new HttpClientCodec());
pipeline.addLast(new ReadTimeoutHandler(HTTP_TIMEOUT_SECONDS, TimeUnit.SECONDS));
pipeline.addLast(new HttpObjectAggregator(MAX_RESPONSE_SIZE));
pipeline.addLast(new WeatherResponseHandler(future, group));
}
});
}
URI buildWeatherUri(String cityName) throws Exception {
String encodedCityName = URLEncoder.encode(cityName.trim(), StandardCharsets.UTF_8.toString());
return new URI(BASE_URL + "/" + encodedCityName + "?format=j1");
}
void sendWeatherRequest(Channel channel, URI uri) {
FullHttpRequest request = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1,
HttpMethod.GET,
uri.getRawPath() + "?format=j1" // 确保参数在路径中
);
HttpHeaders headers = request.headers();
headers.set(HttpHeaderNames.HOST, uri.getHost());
headers.set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
headers.set(HttpHeaderNames.ACCEPT, "application/json");
headers.set(HttpHeaderNames.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
headers.set(HttpHeaderNames.ACCEPT_LANGUAGE, "zh-CN");
channel.writeAndFlush(request).addListener(f -> {
if (!f.isSuccess()) {
logger.error("Failed to send weather request", f.cause());
}
});
}
void handleConnectionFailure(CompletableFuture<String> future, Throwable cause, EventLoopGroup group) {
logger.error("Connection to weather service failed", cause);
future.completeExceptionally(new RuntimeException("无法连接到天气服务"));
group.shutdownGracefully();
}
void handleInitializationError(CompletableFuture<String> future, Exception e, EventLoopGroup group) {
logger.error("Weather service initialization failed", e);
future.completeExceptionally(e);
group.shutdownGracefully();
}
private static class WeatherResponseHandler extends SimpleChannelInboundHandler<FullHttpResponse> {
private final CompletableFuture<String> future;
private final EventLoopGroup group;
public WeatherResponseHandler(CompletableFuture<String> future, EventLoopGroup group) {
this.future = future;
this.group = group;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) {
try {
if (response.status().code() != 200) {
String errorMsg = String.format("天气服务返回错误状态码: %d", response.status().code());
future.complete(errorMsg);
return;
}
String json = response.content().toString(io.netty.util.CharsetUtil.UTF_8);
logger.debug("Received JSON: {}", json); // 记录原始JSON
// 配置ObjectMapper忽略未知属性
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 先验证JSON格式
JsonNode jsonNode = objectMapper.readTree(json);
WeatherResponse weatherResponse = objectMapper.treeToValue(jsonNode, WeatherResponse.class);
// WeatherResponse weatherResponse = objectMapper.readValue(json, WeatherResponse.class);
if (weatherResponse.getCurrent_condition() == null || weatherResponse.getCurrent_condition().isEmpty()) {
future.complete("无法获取天气信息,请检查城市名称是否正确或稍后重试。");
return;
}
CurrentCondition condition = weatherResponse.getCurrent_condition().get(0);
System.out.println("condition = " + condition);
String result = formatWeatherInfo(condition);
future.complete(result);
} catch (Exception e) {
future.completeExceptionally(new RuntimeException("解析天气数据失败", e));
} finally {
ctx.close();
group.shutdownGracefully();
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
future.completeExceptionally(new RuntimeException("获取天气信息时发生错误", cause));
ctx.close();
group.shutdownGracefully();
}
private String formatWeatherInfo(CurrentCondition condition) {
return String.format("""
天气情况: %s
温度: %s°C (体感温度: %s°C)
湿度: %s%%
气压: %s mb
降水量: %s mm
风速: %s km/h (%s方向)
能见度: %s 公里
紫外线指数: %s
观测时间: %s
""",
condition.getWeatherDesc().get(0).getValue(),
condition.getTemp_C(),
condition.getFeelsLikeC(),
condition.getHumidity(),
condition.getPressure(),
condition.getPrecipMM(),
condition.getWindspeedKmph(),
condition.getWinddir16Point(),
condition.getVisibility(),
condition.getUvIndex(),
condition.getLocalObsDateTime()
);
}
}
}
2. 项目重新打包测试
通义灵码测试效果如下:
cherry studio 工具测试如下:
emmme,cherry studio工具依旧报错