Spring AI - MCP 用法和基本测试案例

MCP 的核心作用

如果说大模型(LLM)是操作系统的大脑,那么 MCP(Model Context Protocol,模型上下文协议)就是大模型的 “USB 接口”。在 MCP 出现之前,大模型要连接外部世界(查数据库、调企业 API、看本地文件),每个开发者都要自己用 Python/Java 写一套定制的 Tools(Tools Calling)。这导致了严重的碎片化:你给 OpenAI 写的工具,换到 Claude 或本地的 Ollama 上,可能就要重写适配。

Anthropic 推出的 MCP 彻底改变了这一点。它统一了标准:它建立了一个轻量级、双向的开放协议,让任何大模型(Client)都可以通过统一的标准,秒级接入任何数据源和工具生态(Server)。MCP 的作用可以概括为以下三点:

  • Resources(资源):把静态数据(如:日志文件、DB 某一页、ReadMe)以统一格式读给大模型。
  • Prompts(提示词模板):Server 侧提供预设的 Prompt 模板(如:“帮我 review 这段 Java 代码”)。
  • Tools(工具):让大模型可以 “做动作”(如:在百度地图查经纬度、去 GitHub 提个 PR、在 Redis 里删个 Key)。

关于 MCP 的介绍,也可以参考本站之前写的《Langchain4j - MCP 和 Tool Calling 的对比及其具体案例》


MCP 的正确玩法

在 Demo 阶段,大家喜欢用 stdio(标准输入输出)命令行直接拉起一个本地进程(例如 npx baidu-map),简单粗暴。但如果把这套东西直接扔上生产环境,基本等同于灾难。生产环境需要围绕安全、高可用、隔离和工程化展开。

  • 第一,全面抛弃 stdio,全量拥抱 sse(网关化),绝对不推荐让微服务进程去 fork 本地子进程。在 LLM 和一堆 MCP Server 之间架设一个 MCP 网关(或 API Gateway)。大模型只需要连接这个网关,由网关负责路由、鉴权、限流(Rate Limiting)。比如把百度的、GitHub的、内部基础设施的 MCP Server 全挡在网关后面。
  • 第二,动态工具注入(Dynamic Tool Injection)与权限割裂。大模型并不是越聪明越好,如果每次请求都把几十个工具(比如查地图、删数据库、发邮件)一股脑塞给它,不仅会触发 Token 爆量(如你第一问遇到的 400 错误或上下文浪费),还会导致大模型 “工具选择综合征”(选错工具)。正确的做法是基于用户身份和当前上下文,动态注入工具。普通用户进来了,只注入 query-product-mcp(只读工具);管理员进来了,才动态注入 delete-user-mcp(写工具)。
  • 第三,独裁的 “沙箱化” 与 “降权执行”。生产环境的 MCP Server 往往拥有很高的权限(比如能执行 Shell 命令、查核心 DB)。大模型一旦遭遇提示词注入攻击(Prompt Injection),可能会被攻击者教唆去执行 rm -rf 或拖库。所以:
    • 隔离:MCP Server 必须部署在极度严格的私有网络或 K8s 沙箱环境中,只能单向访问特定资源。
    • 降权:运行 node 或 python MCP 进程的 Linux 用户绝对不能是 root,必须是无特殊权限的临时用户(如 nobody)。
  • 第四,双表单确认机制。涉及到敏感操作的 Tools(如:线上退款、发送邮件给大客户、修改配置),绝对不能让 LLM 自动“一条龙”执行完。MCP Server 在收到敏感动作请求时,不直接执行,而是先在数据库生成一个“待审批任务”,并返回给大模型一个 need_approval_id。大模型向前端输出:“我已经为您准备好了退款申请,请点击下方链接确认。” 只有当真正的人类点击了按钮,Tool 才会真正生效。
  • 第五,分布式追踪与审计。当大模型开始频繁调用 MCP 时,排查线上 Bug 会变成地狱:用户提问 -> LLM 思考 -> 调 MCP A -> 调 MCP B -> LLM 总结 -> 返回。中间任何一个环节慢了或者报错了,怎么查?必须利用 Spring Cloud Sleuth / OpenTelemetry,将 TraceId 强行透传进 MCP 的 SSE 请求头中。在 SkyWalking 或 ELK 上,你可以清晰地看到一条调用链:大模型到底卡在自己思考上,还是卡在百度地图的 API 响应上。

总结起来,实际企业落地的生产架构通常长这样:

1
2
3
4
5
6
7
8
9
10
[ 用户 / 前端 ] 


[ 核心微服务 (ChatClient) ] ──(鉴权获取 Token)──► [ 统一认证中心 ]

▼ (通过 SSE 路由)
[ MCP Gateway 网关 ]
├──► [ 百度地图 MCP Server ] (Pod A) ──► 外部 API
├──► [ 内部权限 DB MCP Server ] (Pod B) ──► 只读从库
└──► [ 自动化运维 MCP Server ] (Pod C) ──► 严格隔离的沙箱环境

总之,就是把大模型当成一个随时可能被黑客控制的、极不稳定的 “实习生”。所有的 MCP Server 就是实习生能调用的内部系统,你必须用网关限流、沙箱隔离、人类审批、链路追踪这些套路,把它死死地锁住。


写一个 MCP 服务端

这里的代码只做演示之用。

首先是依赖配置:

1
2
3
4
5
6
7
8
9
10
<!--注意:spring-ai-starter-mcp-server-webflux 不能和 spring-boot-starter-web 依赖并存-->
<!--web 会使用 tomcat 启动,而不是 netty 启动,从而导致 mcp server 启动失败。虽然程序表面看起来正常,但是 mcp 客户端就是连不上!-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webflux</artifactId><!--核心-->
</dependency>

配置文件:application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 8090
servlet:
encoding:
charset: UTF-8
enabled: true
force: true

spring:
ai:
mcp:
server:
type: async
name: owlias-mcp-server
version: 1.0.0

Tools 的实现(这里举例说明):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;

@Slf4j
public class WeatherTool {
public record WeatherRequest(String location) {}
public record WeatherResponse(String location, String temperature, String info, String wind) {}

@Tool(description = "根据城市名称或地点获取当前的实时天气预报")
public WeatherResponse chatWeather(WeatherRequest request) { // 模拟天气业务逻辑
log.info("请求获取天气信息:{}", request);
if (request.location().contains("北京")) {
return new WeatherResponse("北京", "1℃", "晴朗", "北风3级");
} else if (request.location().contains("上海")) {
return new WeatherResponse("上海", "2℃", "大雨", "东风2级");
} else {
return new WeatherResponse(request.location(), "20℃", "阴天", "微风");
}
}
}

将 Tools 列表暴露出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import demo05.tools.WeatherTool;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class McpServerConfig {

@Bean
public ToolCallbackProvider weatherTools() {
return MethodToolCallbackProvider.builder()
.toolObjects(new WeatherTool())
.build();
}
}


客户端使用 MCP

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId><!--核心-->
</dependency>

配置文件:application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
server:
port: 8080
servlet:
encoding:
charset: UTF-8
enabled: true
force: true

spring:
ai:
dashscope: # 默认注入的 bean 名称 dashScopeChatModel
api-key: ${QWEN_API_KEY}
openai: # 默认注入的 bean 名称 openAiChatModel
api-key: api-key-xxx
base-url: http://localhost:11434
chat:
options:
model: gemma3:1b # 这个模型不具备 tools calling 和 MCP 调用功能!
mcp:
client:
type: async
request-timeout: 60s
toolcallback:
enabled: true
sse:
connections:
mcp-server1:
url: http://localhost:8090

logging:
level:
org.springframework.ai.chat.client.advisor: DEBUG

配置类:SsaLLMConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.time.Duration;

@Configuration
public class SsaLLMConfig {

private static final String apiKey;
static {
apiKey = System.getenv("QWEN_API_KEY");
}

@Bean
public RestClientCustomizer restClientCustomizer() {
return restClientBuilder -> {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofSeconds(300)); // 全局注入,解决超时
factory.setConnectTimeout(Duration.ofSeconds(100));
restClientBuilder.requestFactory(factory);
};
}

@Bean("dashScopeApi")
public DashScopeApi dashScopeApi() {
return DashScopeApi.builder().apiKey(apiKey).build();
}

@Bean("deepseekChatModel")
public ChatModel deepseekChatModel(DashScopeApi dashScopeApi) { // 注意这里的 deepseekChatModel 没被赋予 ToolCallback 👈🏻
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi)
.defaultOptions(DashScopeChatOptions.builder().model("deepseek-v4-flash").build())
.build();
}

@Bean("deepseekChatClient") // 当然也可以使用ChatClient包装本地LLM,只是某些小模型不支持tools calling 或 MCP
public ChatClient deepseekChatClient(@Qualifier("deepseekChatModel") ChatModel deepseekChatModel,
ToolCallbackProvider tools) {
return ChatClient.builder(deepseekChatModel)
.defaultToolCallbacks(tools.getToolCallbacks()) // mcp 协议,配置参考 application.yml 👈🏻
.defaultAdvisors(new SimpleLoggerAdvisor()) // logging.level.org.springframework.ai.chat.client.advisor: DEBUG
.build();
}
}

业务测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping("/mcp-test")
public class McpClientTestController {

@Resource(name = "deepseekChatClient")
private ChatClient chatClient;

@Resource(name = "deepseekChatModel")
private ChatModel chatModel;

@GetMapping(value = "/test01", produces = MediaType.TEXT_PLAIN_VALUE) // 具备 MCP
public Flux<String> test01(@RequestParam(defaultValue = "北京天气") String message) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}

@GetMapping(value = "/test02", produces = MediaType.TEXT_PLAIN_VALUE) // 没有 tools calling 或 MCP 的 deepseek 会胡说八道!
public Flux<String> test02(@RequestParam(defaultValue = "北京天气") String message) {
return chatModel.stream(message);
}
}


调用第三方的 MCP 服务

这里以百度地图为例说明。参考:MCP.so 百度地图 MCP Server。百度地图已经全面拥抱 MCP 协议,只是它默认采用了本地 stdio fork 子进程的方式,不推荐使用这种方式部署到生产,这里仅作为演示操作。

使用百度地图,让我们本地大模型具有查位置查天气等功能,我们只需要在上述 客户端使用 MCP 的基础上加一个配置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
ai:
mcp:
client:
type: async
request-timeout: 60s
toolcallback:
enabled: true
sse:
connections:
mcp-server1:
url: http://localhost:8090
stdio:
# 通过 stdio 方式配置的服务器进程,其生命周期是完全绑定在宿主微服务进程上的。
## 拉起机制:Spring AI 的 MCP Client 在初始化时,会读取你的 mcp-server.json5 配置,
## 利用 Java 的 ProcessBuilder 或类似的进程工具,在底层派生(Fork)出一个子进程来执行 npx 命令。
#### 销毁机制:Spring 的 McpClient 实现了 AutoCloseable 接口,并作为一个标准的 Spring Bean 被管理。
#### 当你的微服务关闭时,Spring 容器会触发 Bean 的销毁生命周期(preDestroy / close),此时 MCP Client
#### 会显式地向子进程发送 SIGTERM(或在 Windows 上执行销毁动作)来终止这个 npx 进程。
######【注意】服务在关闭时,被人用 kill -9 强行终止,或者部署容器(如 Docker/K8s)超时后直接强制销毁,Java 进程将没有机会
###### 执行 Spring 容器的销毁钩子(Shutdown Hook)。这时,拉起的子进程就会变成孤儿进程(Orphan Process),继续留在操作系统中。
###### 所以请使用标准的 kill -15(SIGTERM)或者通过 Spring Boot Actuator 的 /shutdown 端点,给 Java 留出几秒钟去清理子进程。
servers-configuration: classpath:/mcp-server.json5

resources/mcp-server.json5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"mcpServers": {
"baidu-map": {
"command": "npx",
"args": [
"-y",
"@baidumap/mcp-server-baidu-map"
],
"env": {
"BAIDU_MAP_API_KEY": "xxx" // 填入你自己申请的 BAIDU_MAP_API_KEY
}
}
}
}