【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端

写在开头

上一篇讲了 MCP 的概念:标准协议、三大角色、M+N 替代 M×N。

落地时你会站在两个位置之一(或者都站):接入别人提供的能力(做客户端),或者把自己的能力开放出去(做服务端)。这篇把两个视角的代码都写全——你会发现 Spring AI 把两条路都铺得极平,其中一条路甚至只需要一个注解。

MCP 的两种角色

左:用能力;右:供能力;中间是同一套协议


一、做客户端:接入外部 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,等于授权"任何接入方"可以调用它。

三条底线:

  1. 白名单思维:只暴露该暴露的方法,别图省事把整个 Service 都标出去;
  2. 描述即契约:工具描述要写清能力边界,防止调用方误用;
  3. 传输隔离:本地工具用 STDIO(只在本机子进程里跑,外部够不着),对外服务用 HTTP 部署并叠加认证——两种传输方式的差异天然对应两种信任级别。

客户端这一侧也有讲究:接入第三方 MCP 服务 = 信任第三方的代码在你环境里执行。来源不明的 MCP 服务,谨慎接入。


五、这篇的四个要点

  1. 客户端三步——starter + 配置连接 + 显式 .tools(mcpTools);不自动挂载是有意的设计(启动不受外部服务拖累)。
  2. 服务端一个注解——@McpTool 替代 @Tool,业务逻辑零改动即可对生态开放。
  3. 本地与远程工具同接口混用,重名冲突框架直接报错。
  4. 暴露即授权——服务端白名单思维 + 传输隔离;客户端谨慎接入来源不明的服务。

延伸思考

第三辑到这里,你的 AI 应用已经能"做事"了——通过本地工具和 MCP 生态,它能查系统、调服务、操作外部世界。

但第 02 篇还有一个边界没解决:它依然"不知道"——不知道你公司的产品文档、内部制度、私有数据。让它答这类问题时依然在"编"。

第四辑 RAG,把"闭卷考试"变"开卷考试"。


下一篇

《RAG 要解决什么:把"闭卷考试"变成"开卷"》——幻觉的两种来源、为什么这个场景不选微调、以及 RAG 的最小闭环长什么样。

第四辑正式开始。


本专栏按 Spring AI 当前版本讲解。

Logo

更多推荐