【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端
【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端
写在开头
上一篇讲了 MCP 的概念:标准协议、三大角色、M+N 替代 M×N。
落地时你会站在两个位置之一(或者都站):接入别人提供的能力(做客户端),或者把自己的能力开放出去(做服务端)。这篇把两个视角的代码都写全——你会发现 Spring AI 把两条路都铺得极平,其中一条路甚至只需要一个注解。

左:用能力;右:供能力;中间是同一套协议
一、做客户端:接入外部 MCP 服务
场景:你要做一个 AI 助手,想让它能用上生态里现成的 MCP 工具(比如别人提供的查询、文件操作能力)。
1.1 引入客户端 starter 并配置连接
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
spring:
ai:
mcp:
client:
enabled: true
name: my-ai-assistant
version: 1.0.0
type: SYNC
request-timeout: 30s
# 方式一:本地子进程(STDIO),适合本机工具
stdio:
connections:
filesystem:
command: npx
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- "/data/workspace"
# 方式二:远程 HTTP 服务(SSE / Streamable),适合跨网络调用
sse:
connections:
order-service:
url: https://mcp.example.com
sse-endpoint: /sse
两种传输方式可以同时配置,自动配置会把它们发现的工具合并到同一个 ToolCallbackProvider 里。
自动配置会做两件事:连接所有配置的服务器 → 发现它们提供的工具 → 把这些工具暴露成一个 ToolCallbackProvider Bean。
1.2 关键一步:显式挂进 ChatClient
这里有个容易踩的设计点:MCP 工具提供者不会自动注册到 ChatClient。
原因很务实:挂载意味着启动时要向每个 MCP 服务器发起网络请求获取工具清单——启动速度和健壮性都会被外部服务拖累。所以框架把它交给你,按需显式接入:
@Service
public class AssistantService {
private final ChatClient chatClient;
private final SyncMcpToolCallbackProvider mcpTools; // 自动配置的 MCP 工具集
public AssistantService(ChatClient.Builder builder, SyncMcpToolCallbackProvider mcpTools) {
this.mcpTools = mcpTools;
this.chatClient = builder
.defaultSystem("你是一个全能助手。")
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.tools(mcpTools) // ← 显式挂载 MCP 发现的工具
.call()
.content();
}
}
从模型和框架的视角看,MCP 工具和你本地的 @Tool 方法没有任何区别——它们实现了同一个 ToolCallback 接口,模型不知道也不关心工具到底执行在哪台机器上。
二、做服务端:把自家能力开放出去
场景:你公司有一套成熟的订单系统,希望所有 AI 应用(公司的、第三方的)都能"查订单"。
2.1 引入服务端 starter
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
2.2 一个注解暴露能力
@Service
@RequiredArgsConstructor
public class OrderMcpService {
private final OrderService orderService;
@McpTool(description = "根据订单号查询订单状态:待付款/已发货/已签收/已取消")
public String getOrderStatus(
@McpToolParam(description = "订单号", required = true) String orderId) {
return orderService.queryStatus(orderId);
}
@McpTool(description = "统计某用户近 30 天的订单数量")
public int countRecentOrders(@McpToolParam(description = "用户 ID") String userId) {
return orderService.count(userId);
}
// 返回结构化对象:框架会按 JSON Schema 生成参数与返回值描述
@McpTool(description = "分页查询用户订单列表,按创建时间倒序")
public PageResult<OrderVO> listOrders(
@McpToolParam(description = "用户 ID", required = true) String userId,
@McpToolParam(description = "页码,从 1 开始") int page,
@McpToolParam(description = "每页条数,最大 50") int size) {
int safeSize = Math.min(size, 50);
return orderService.page(userId, page, safeSize);
}
}
对应的返回对象用普通 POJO 即可,字段名就是模型看到的字段名:
public record OrderVO(String orderId, String status, BigDecimal amount, LocalDateTime createdAt) {}
public record PageResult<T>(long total, int page, int size, List<T> records) {}
服务端还可以按需开启 HTTP 传输并叠加认证,把能力真正开放到网络上:
spring:
ai:
mcp:
server:
name: order-mcp-server
version: 1.0.0
protocol: STREAMABLE # 对外服务推荐 Streamable HTTP
type: SYNC
@Configuration
public class McpServerSecurityConfig {
@Bean
SecurityFilterChain mcpSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/mcp/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/mcp/**").hasAuthority("SCOPE_mcp.invoke"))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
和本地工具的 @Tool 相比,只换了注解名。服务端 starter 会自动扫描这些方法、生成工具描述和参数结构、通过协议暴露出去。
这就是能力定义与暴露方式的解耦:同一个业务方法,标 @Tool 给本地模型用,标 @McpTool 对全世界开放——业务逻辑一行不改。
三、两种工具混用:同一个接口,一个清单
本地工具和 MCP 远程工具可以同时挂进一次调用:
@Service
@RequiredArgsConstructor
public class MixedToolService {
private final ChatClient chatClient;
private final SyncMcpToolCallbackProvider mcpTools;
public String handle(String question) {
return chatClient.prompt()
.user(question)
.tools(new LocalTools(), mcpTools) // 本地 + 远程,混着来
.call()
.content();
}
}
// 本地工具:只在本进程内执行,不经过网络
public class LocalTools {
@Tool(description = "把异常信息记录到内部工单系统,返回工单号")
public String createTicket(@ToolParam(description = "异常摘要") String summary) {
return ticketService.create(summary);
}
}
框架对合并后的工具清单做名称冲突校验——两个工具重名会直接抛异常,宁可不执行,也不能调错对象。这是安全设计的一部分,具体怎么在启动阶段提前发现,放到后面的「进阶」小节里说。
进阶:几个工程化细节
主流程跑通之后,真正上生产还会遇到几个细节问题。这一节集中处理,不影响前面的主线理解。
只挂载部分工具:白名单过滤
MCP 服务器往往一次暴露很多工具,但你的场景可能只允许读、不允许写。这时不要整个 mcpTools 挂上去,而是按名字过滤后再挂:
@Service
public class RestrictedAssistantService {
private final ChatClient chatClient;
private final List<ToolCallback> allowedTools;
public RestrictedAssistantService(ChatClient.Builder builder,
SyncMcpToolCallbackProvider mcpTools) {
// 只保留白名单里的工具,其余一律不暴露给模型
Set<String> whitelist = Set.of("read_file", "list_directory");
this.allowedTools = Arrays.stream(mcpTools.getToolCallbacks())
.filter(tc -> whitelist.contains(tc.getToolDefinition().name()))
.toList();
this.chatClient = builder.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.toolCallbacks(allowedTools)
.call()
.content();
}
}
启动自检:确认连接与工具清单
外部 MCP 服务挂掉时,最好别让整个应用起不来,也别让问题拖到第一次调用才暴露。启动时探一下连接、打印发现的工具清单,排查会轻松很多:
@Slf4j
@Component
public class McpHealthChecker implements ApplicationRunner {
private final SyncMcpToolCallbackProvider mcpTools;
public McpHealthChecker(SyncMcpToolCallbackProvider mcpTools) {
this.mcpTools = mcpTools;
}
@Override
public void run(ApplicationArguments args) {
ToolCallback[] callbacks = mcpTools.getToolCallbacks();
log.info("已发现 {} 个 MCP 工具:{}", callbacks.length,
Arrays.stream(callbacks)
.map(tc -> tc.getToolDefinition().name())
.toList());
}
}
启动自检:提前发现工具重名
前面说过重名工具会让框架直接抛异常。与其等运行时报错,不如在启动阶段就把冲突打出来,定位到具体是哪个名字重复:
@Slf4j
@Component
public class ToolConflictChecker implements ApplicationRunner {
private final SyncMcpToolCallbackProvider mcpTools;
public ToolConflictChecker(SyncMcpToolCallbackProvider mcpTools) {
this.mcpTools = mcpTools;
}
@Override
public void run(ApplicationArguments args) {
Map<String, Long> counter = Arrays.stream(mcpTools.getToolCallbacks())
.collect(Collectors.groupingBy(
tc -> tc.getToolDefinition().name(), Collectors.counting()));
counter.forEach((name, count) -> {
if (count > 1) {
log.error("MCP 工具名冲突:{} 出现 {} 次,请重命名后再启动", name, count);
}
});
}
}
这三个细节的共同点是:都在启动阶段把外部依赖的不确定性提前暴露,而不是留到模型真正调用工具时才炸。
四、选型与安全边界
4.1 怎么选角色
| 你的诉求 | 角色 |
|---|---|
| 让自己的 AI 应用更强大,接入现成能力 | 客户端 |
| 让自家系统能力被所有 AI 应用复用 | 服务端 |
| 大公司的典型形态 | 两者都做:消费生态,也输出生态 |
4.2 安全边界:暴露即授权
做服务端时必须清醒地认识到:你暴露的每一个 @McpTool,等于授权"任何接入方"可以调用它。
三条底线:
- 白名单思维:只暴露该暴露的方法,别图省事把整个 Service 都标出去;
- 描述即契约:工具描述要写清能力边界,防止调用方误用;
- 传输隔离:本地工具用 STDIO(只在本机子进程里跑,外部够不着),对外服务用 HTTP 部署并叠加认证——两种传输方式的差异天然对应两种信任级别。
客户端这一侧也有讲究:接入第三方 MCP 服务 = 信任第三方的代码在你环境里执行。来源不明的 MCP 服务,谨慎接入。
五、这篇的四个要点
- 客户端三步——starter + 配置连接 + 显式
.tools(mcpTools);不自动挂载是有意的设计(启动不受外部服务拖累)。 - 服务端一个注解——
@McpTool替代@Tool,业务逻辑零改动即可对生态开放。 - 本地与远程工具同接口混用,重名冲突框架直接报错。
- 暴露即授权——服务端白名单思维 + 传输隔离;客户端谨慎接入来源不明的服务。
延伸思考
第三辑到这里,你的 AI 应用已经能"做事"了——通过本地工具和 MCP 生态,它能查系统、调服务、操作外部世界。
但第 02 篇还有一个边界没解决:它依然"不知道"——不知道你公司的产品文档、内部制度、私有数据。让它答这类问题时依然在"编"。
第四辑 RAG,把"闭卷考试"变"开卷考试"。
下一篇
《RAG 要解决什么:把"闭卷考试"变成"开卷"》——幻觉的两种来源、为什么这个场景不选微调、以及 RAG 的最小闭环长什么样。
第四辑正式开始。
本专栏按 Spring AI 当前版本讲解。
更多推荐

所有评论(0)