[AI工程] Spring AI 第廿一篇:存量 REST 接口的 MCP 化改造实录——工具从哪条路径注册、参数描述怎么写、身份怎么过去


💡 第九篇讲的是"怎么起一个 MCP Server":加 starter、写一个 @McpTool、客户端连上、工具列出来,一个 Demo 就通了。真到公司里做这件事,问题完全不是这个形状。

我手上这套航空客服 Demo 后面挂着三十多个存量 REST 接口,跑了五六年,改签、退票、选座、行程单、航班动态全在里面。要"成规模地包",第一个问题就是:这三十个是不是都能出去?谁说了算?第二个问题是包出去之后模型选不对------同义词、参数含义、什么时候不该用,这些原来都写在页面上,现在得写进 description。第三个问题最隐蔽:用户在你们自己的系统里过了鉴权,工具在 MCP 这一侧拿不到"我是谁",因为 2.0.1 的自动转换器只往 ToolContext 里塞了一个 exchange。

这一篇就把这三件事按 2.0.1 的源码讲清楚:接口怎么盘点分级、工具注册的三条路径各自丢了什么、参数描述与幂等提示怎么写、传输选型(配置默认值其实早变了)、身份怎么通过 _meta 接回来,以及作为客户端接入别人的 MCP 时配置长什么样。所有判断都按本地 jar 与 Maven Central 上拉的 sources jar 逐条 javap 核对。 存量接口的 MCP 化改造实录,盘点分级、注册路径、参数描述与身份通道各管哪一段

1. 先盘点:一批接口不是都能出去

改造的第一步不是加注解,是把"能开放给模型的接口"和"只能内部调的接口"分开。

第九篇的链路是"我要提供一个能力",本篇的链路是"我有一堆能力,哪些能给模型用"。这两件事的差别在于谁来触发:HTTP 接口由人点,MCP 工具由模型选。模型选的意思是------它看不到你的页面、不知道你们的业务约定、也不为你的错误兜底。

text 复制代码
HTTP 调用方(人)    --> 看得见按钮 --> 只点允许点的 --> 错了自己承担
MCP 调用方(模型)  --> 只看得见 name + description + inputSchema
                    --> 描述里没写的约束它不知道
                    --> 参数是它自己拼的 JSON
                    --> 出错时它看到的只有 content 里那段文本

所以盘点要按四个维度打分,而不只是"这个接口有没有 GET"。

维度 问的问题 不通过的表现
读写性 会不会落库、发消息、扣钱 有副作用却当成查询接口包出去
幂等性 重复调两次结果一样吗 模型重试一次 = 用户被扣两次
副作用范围 影响单条记录还是一批 批量接口一旦开放就是放大器
参数可自然语言表达度 用户会不会这么说话 要用户报"票号类型 L+9 位"的接口不适合直给

四个维度里,参数可表达度 最容易被后端低估。举个例子:POST /pnr/{code}/parse 这个接口,输入是航信 PNR 六位码,返回一大段结构化文本。技术上它是只读的、幂等的,看起来很适合包;实际上没有任何用户会对着聊天框说"帮我解析 PNR QW7YT3"。这类接口包成工具之后只会占坑,还会在模型犹豫时被误选。

Q1:存量接口要不要为了 MCP 化重写一层 Service?

不要。要加的是一层工具适配层,不是新的业务层。这两者的区别很实在:

做法 内容 判断
直接在老 Service 方法上加 @Tool 老方法签名被迫接受 ToolContext、返回值要改成"模型看得懂的形状" 侵入,且老接口还有页面在用,改一次回归一片
加一层 XxxToolAdapter 适配器调老 Service,负责裁剪字段、翻译枚举、屏蔽敏感列 推荐。业务代码零改动,工具形状可独立演进
重写一层领域服务 为 AI 单独实现一套查询 过度设计,两套逻辑早晚漂移

我的做法是固定三类工具包:query(只读,A 档)、apply(写,B 档,必须带确认)、internal(不开放,C 档),包名就能当权限边界用,第 4 节的 McpToolFilter 直接按包名过滤。

Demo 的盘点结果,去掉重复的后 32 个接口最后剩这些:

存量接口 读写 幂等 副作用 用户会这么说吗 档位
航班动态查询 只读 --- 无 会,"CA1836 现在到哪了" A
订单详情查询 只读 --- 无 会 A
行程单下载 只读 --- 产出含证件号的文件 会 A′(必须先脱敏)
客票状态查询 只读 --- 无 不会(要票号) C(留内部)
选座 写 否(重复选报错) 锁座 会 B
改签申请 写 否 改签费 会,但分支多 B
退票申请 写 否 资金 会 B+(强制人工确认)
批量退票(运维) 批量写 否 资金 --- C 不开放
PNR 解析 只读 是 入参含凭证 不会 C

32 个包成 6 个工具,是这一篇里最划算的一次投入。真正上线后模型选错工具的概率,比"多包几个接口让能力更全"带来的收益高得多------原因在第 3 节:description 是抢答的,工具越多、越相似,选错率越高。


2. 三条注册路径:源码里各自长什么样

同一个 @Tool 方法,走不同路径暴露成 MCP 工具,得到的东西不一样。

2.0.1 里把能力挂成 MCP 工具,实际有三条路径,全部在 spring-ai-autoconfigure-mcp-server-common 里汇合。

text 复制代码
路径 A  @Tool / ToolCallback  --> ToolCallbackConverterAutoConfiguration --> List<SyncToolSpecification>
路径 B  @McpTool              --> McpServerSpecificationFactoryAutoConfiguration --> List<SyncToolSpecification>
路径 C  自己 new McpServerFeatures.SyncToolSpecification(...)  --> 同名 bean 直接被聚合
                                          ↓
                              McpSyncServer / McpAsyncServer --> tools/list

2.1 路径 A:ToolCallback 自动转换(复用已有工具)

这条路径的价值是零 MCP 代码 :你在第八篇、第十一篇里写给 ChatClient 用的那些 @Tool,可以直接再暴露成 MCP 工具。它的入口条件很宽,ToolCallbackConverterAutoConfiguration.syncTools(...) 的形参就是证据:

java 复制代码
@Bean
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
        matchIfMissing = true)
public List<McpServerFeatures.SyncToolSpecification> syncTools(
        ObjectProvider<List<ToolCallback>> toolCalls,
        List<ToolCallback> toolCallbackList,
        ObjectProvider<List<ToolCallbackProvider>> tcbProviderList,
        ObjectProvider<ToolCallbackProvider> tcbProviders,
        McpServerProperties serverProperties) { ... }

四种 bean 形态都吃:List<ToolCallback>、裸 List<ToolCallback> 注入、List<ToolCallbackProvider>、单个 ToolCallbackProvider。所以业务侧只要暴露一个 provider bean 就够了:

java 复制代码
@Component
public class FlightOrderToolAdapter {

    private final OrderQueryService orderQueryService;   // 五年历史的老类,一行不动

    @Tool(name = "flight_order_query",
          description = "按机票订单号查询订单状态、航段与乘机人列表。只读,不产生任何变更。")
    public OrderBriefVO query(
            @ToolParam(description = "机票订单号,13 位纯数字") String orderNo,
            ToolContext toolContext) {
        return orderQueryService.brief(orderNo);
    }
}

@Configuration
class McpToolBridgeConfig {

    @Bean
    ToolCallbackProvider flightOrderToolProvider(FlightOrderToolAdapter adapter) {
        return MethodToolCallbackProvider.builder().toolObjects(adapter).build();
    }
}

然后是这一节最该记住的一段源码------toSharedSyncToolSpecification 构造 MCP Tool 时只取了三个字段:

java 复制代码
var tool = McpSchema.Tool
    .builder(toolCallback.getToolDefinition().name(),
            new JacksonMcpJsonMapper(JacksonUtils.getDefaultJsonMapper()),
            toolCallback.getToolDefinition().inputSchema())
    .description(toolCallback.getToolDefinition().description())
    .build();

McpSchema.Tool 的完整形状里有 name / title / description / inputSchema / outputSchema / annotations / meta / icons,自动转换只填了 name + description + inputSchema。三个结论直接抄过来不能用:

丢件 后果 修法
annotations(五个 hint) 调用方无法知道这是只读还是会扣钱 走路径 B 或 C
title 客户端界面只能显示机器名 同上
outputSchema 返回值形状全靠模型猜 同上;@McpTool 有 generateOutputSchema

还有两处更安静的行为,我踩到的时候第一反应是"是不是我起错了服务":

java 复制代码
// De-duplicate tools by their name, keeping the first occurrence of each tool name
return tools.stream()
    .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool,
            (existing, replacement) -> existing))
    ...
  • 同名工具静默去重,保留第一个,没有一行日志。 两个老团队各自包了 order_query,其中一个从 tools/list 里消失,你在服务端日志里查不到任何痕迹。治理办法只有一个:工具名带系统前缀(我建议 订单中心 → oc_),或者在 CI 里把 tools/list 拉下来比对数量。
  • 异常原文会跨协议出去 。同一个方法里 catch (Exception e) 之后返回 CallToolResult.builder().content(List.of(TextContent.builder(e.getMessage()).build())).isError(true)。第十八篇讲过"异常消息回灌模型",在 MCP 这里性质更严重一点:拿到 e.getMessage() 的是外部客户端,而 JDBC/Feign 的异常文本里出现连接串和内部主机名的概率不低。适配层必须自己 catch 掉,只回可读文案。

2.2 路径 B:@McpTool 注解(要 hint 就走这条)

注解在 spring-ai-mcp-annotations:2.0.1,包名是 org.springframework.ai.mcp.annotation。这里有个纯踩坑点:同名的社区包 org.springaicommunity.mcp.annotation(mcp-annotations:0.9.0)也在 Maven Central 上,注解类名完全一样、包名不一样 ,IDE 自动 import 时选错就变成"注解加了没生效"------因为服务端工厂类的条件是 @ConditionalOnClass(McpTool.class),认的是 spring-ai 那个。

@McpTool 的属性与嵌套 McpAnnotations 的默认值:

java 复制代码
public @interface McpTool {
    String name() default "";
    String description() default "";
    McpAnnotations annotations() default @McpTool.McpAnnotations;
    boolean generateOutputSchema() default false;
    String title() default "";
    Class<? extends MetaProvider> metaProvider() default ...;
}

public @interface McpAnnotations {
    String title() default "";
    boolean readOnlyHint()    default false;
    boolean destructiveHint() default true;
    boolean idempotentHint()  default false;
    boolean openWorldHint()   default true;
}
java 复制代码
@Service
public class FlightSeatMcpService {

    @McpTool(name = "seat_select", title = "选座",
             description = "为指定订单的某个航段选一个座位。会占用座位资源;同一座位重复选择会失败。",
             annotations = @McpTool.McpAnnotations(
                     readOnlyHint = false, destructiveHint = false,
                     idempotentHint = false, openWorldHint = false))
    public SeatResult select(
            @McpToolParam(description = "机票订单号,13 位纯数字") String orderNo,
            @McpToolParam(description = "三字航段,如 PEK-SHA") String segment,
            @McpToolParam(required = false, description = "座位号;不传则由系统分配") String seatNo) {
        return seatService.select(orderNo, segment, seatNo);
    }
}

注意 destructiveHint 的默认值是 true ,openWorldHint 默认 true 。这组默认值是"假定你很危险、假定你会碰外部世界",方向是对的,但代价是:只要写 @McpTool 忘了显式声明,一个查询接口也会被标成破坏性工具,客户端的安全策略很可能直接把它藏掉。结论:五个 hint 必须逐个显式写,别用默认。

2.3 路径 C:手写 SyncToolSpecification

当你要拿到请求上下文(第 6 节的身份问题)或要控制 Tool 的全部字段时,只能走这条。构造器就两个参数:

java 复制代码
new McpServerFeatures.SyncToolSpecification(tool, callHandler);
// callHandler: BiFunction<McpSyncServerExchange, CallToolRequest, CallToolResult>

关键在于 CallToolRequest 是个 record,带 name() / arguments() / meta()------meta() 就是 MCP 的 _meta,也是唯一能把调用方身份带进来的位置。

路径 A @Tool 路径 B @McpTool 路径 C 手写
复用已有 ChatClient 工具 ✅ ❌ 要重写 ❌
title / hints / outputSchema ❌ ✅ ✅
能读到 _meta ❌ ❌ ✅
异常文案可控 ❌(原文出去) 部分 ✅
代码量 一行注解 一行注解 每个工具 20~40 行
我的用法 只读查询类 写操作类 身份相关 / 需要确认的类

3. 参数与返回描述:模型选不选得对全看这一层

页面时代这些约束写在 placeholder 和 tooltip 里,MCP 之后只剩 description 一个载体。

先看框架的默认值,这决定了"什么都不写"会发生什么:

来源 属性 默认 进 schema 的样子
@ToolParam required() true 全部参数都必填
@ToolParam description() "" 生成时回落到 "no description"
@McpToolParam required() / description() 同上 同上
JsonSchemaGenerator ToolContext 参数 显式跳过 不出现在 schema(第十八篇那条)

也就是说:@ToolParam 忘了写 description,schema 里那一栏就是 "no description",模型只能靠参数名猜。required 默认 true 更阴------你想让它可选,必须显式 required = false,否则模型每次都硬凑一个值填进去。

3.1 从 OpenAPI 生成工具定义,能做到哪一步

先说结论:2.0.1 的 BOM 里没有任何 OpenAPI / swagger 相关 artifact (我把 BOM 的 169 个 artifactId 全列出来搜过,openapi、swagger 一个都没有)。想做就要自己写生成器。能不能自动化,分三块看:

内容 能不能自动 依据 / 陷阱
inputSchema 的骨架 能 OpenAPI 的 parameters + requestBody.schema 与 JSON Schema 同构,$ref 要自己展开
工具 name 能,但会漂移 operationId 改名 = 工具名变 = 调用方与评测集全断;必须把 name 冻结成常量表
参数 required 能 OpenAPI 的 required: [] 数组语义准确,比 @ToolParam 默认 true 靠谱
description 语义 基本不能 OpenAPI 里存的是给人看的接口说明("根据主键查询"),不是给模型看的选用依据
枚举的业务含义 不能 cabinClass: Y/C/F 不会告诉你"用户说头等舱时该填 F"
分页 / 游标参数 不能 模型不会主动翻页,要在 description 里写死"一次最多 20 条,超出请缩小条件"
幂等键 不能 必须自己加一个 requestId 参数并在描述里说明用法
认证头 必须剔除 Authorization / Cookie 参数绝不能进 schema,否则模型会尝试填它

我的做法是把 OpenAPI 当脚手架 而不是当事实来源 :脚本从 swagger.json 生成 @McpToolParam 的骨架(name / type / required / 原始 description 注释保留),然后人工逐个改写 description,改完的 java 文件进仓库、走 code review。生成器跑一次就废,不要试图维护"每次接口变更就重新生成一遍"------那等于把 description 的所有权交还给写 swagger 的人。

3.2 description 的写法:四件事说全

我把踩过的坑归纳成一条模板,工具描述四段、参数描述三段:

text 复制代码
工具描述 = ①做什么(动词 + 业务对象)
         + ②什么时候该用它(用户的自然说法,2~3 个例子)
         + ③什么时候不该用它(和哪个工具易混、什么前置条件没满足)
         + ④副作用(只读 / 会占用资源 / 会产生费用 / 不可撤销)

参数描述 = ①格式(位数、大小写、示例值)
         + ②从哪来("来自 flight_order_query 返回的 segments[].seq")
         + ③不填会怎样("不传则由系统分配")

同一件事的两种写法,差别非常直接:

text 复制代码
反例:orderNo - 订单号
正例:orderNo - 机票订单号,13 位纯数字,形如 8880123456789;
      用户报出"6 位字母数字码"时那不是订单号,是 PNR,不要调本工具

反例:查询订单
正例:按机票订单号查询订单状态、航段与乘机人列表。只读,不产生任何变更。
      当用户问"我这单到哪一步了 / 还没出票吗"时用本工具。
      如果用户只给了乘机人姓名,先用 passenger_order_search,不要用本工具。

"②自然说法"和"③什么时候不该用"是最值钱的两段,也恰恰是自动生成给不了的。第 ③ 段尤其反直觉:描述相似的两个工具,给其中一个写"不要用我"比给两个都写"用我"更有效。

3.3 返回结构:少即是多

McpToolUtils 的自动转换里,工具返回值最终被塞成 TextContent;SyncMcpToolCallback 在客户端侧再把整个 content 数组序列化成 JSON 字符串交回模型:

java 复制代码
return jsonHelper.toJson(response.content());

也就是说模型看到的是一个数组的 JSON 文本,而不是你 Service 返回的对象。三个直接影响:

  • 老接口那些 code / msg / requestId / extInfo / traceVo 会整段进去,白烧 token。适配层必须裁到"回答这个问题需要的字段"。
  • 日期别只给 2026-09-26T14:30:00,模型对"起飞还有多久"这类问题需要一个已经算好的相对量 。我统一加一个 descForHuman 字段。
  • 需要返回图片时(值机凭证、座位图)不是把 base64 塞进文本,而是配 spring.ai.mcp.server.tool-response-mime-type.<toolName>=image/png。转换逻辑里判断的是 mimeType.toString().startsWith("image"),命中则包成 ImageContent,audience 固定 ASSISTANT。
Q1:为什么不干脆把整个 DTO 丢回去,让模型自己找?

因为模型的"自己找"是按概率,不是按遍历。字段越多、越像(orderStatus 和 status 和 payStatus 同时在场),它抓错的机会越大,而且抓错之后不会报错,会自信地继续 。我的经验值(非官方指标):单个工具返回值控制在能一屏读完的范围,超过就加分页或加"字段子集"变体工具,比如 flight_order_detail 和 flight_order_status_only 两个版本,用 description 区分何时用哪个。


4. 幂等与只读提示:hints 是写给调用方看的安全声明

MCP 比 Spring AI 自己的 tools 多了一件东西:ToolAnnotations。

McpSchema.ToolAnnotations 一共六个字段(javap 结果):

hint 语义 默认值(@McpTool 侧) 谁在消费
title 展示名 空 客户端 UI
readOnlyHint 不会改状态 false 客户端可自动放行
destructiveHint 可能造成不可逆破坏 true 客户端弹确认/禁用
idempotentHint 重复调用等价 false 决定能不能重试
openWorldHint 会触达外部世界 true 决定是否走隔离环境
returnDirect 结果直接返回不再进模型 无 少一轮 token

真正值得注意的是这套 hint 的性质 :它是"声明",不是"强制"。协议不校验你说 readOnlyHint=true 的工具是否真的只读,客户端信了就是信任你的自律。这带来两个动作:

  1. 服务端:按第 1 节的档位把三档写死成一张映射表,评审时逐工具核对,别让提交人自由发挥。
档位 readOnly destructive idempotent openWorld 服务端额外要求
A 只读 true false true false 出参脱敏
B 写(可补偿) false false false false 必填 requestId 幂等键
B+ 资金 / 不可撤销 false true false false 人工确认(第 6.4 节)
出网调用外部 视情况 视情况 视情况 true 沙箱或只读凭证
  1. 客户端 :用 McpToolFilter 做准入。它的定义就一行,spring-ai-mcp 里:
java 复制代码
public interface McpToolFilter extends BiPredicate<McpConnectionInfo, McpSchema.Tool> { }

挂在 provider 的 builder 上(SyncMcpToolCallbackProvider.builder() 一共有三个治理钩子:toolFilter / toolNamePrefixGenerator / toolContextToMcpMetaConverter):

java 复制代码
@Bean
ToolCallbackProvider airlineMcpTools(List<McpSyncClient> clients) {
    return SyncMcpToolCallbackProvider.builder()
            .mcpClients(clients)
            .toolFilter((connectionInfo, tool) -> {
                var ann = tool.annotations();
                if (ann == null) {
                    return isReadOnlyByConvention(tool.name());   // 没声明的一律按名字前缀兜底
                }
                return Boolean.TRUE.equals(ann.readOnlyHint())
                        || !Boolean.TRUE.equals(ann.destructiveHint());
            })
            .build();
}

ann == null 这个分支不是防御性写法,是必须 的:第 2.1 节已经证明路径 A 自动转换出来的工具不带任何 annotations,所以"来自别的团队的 MCP Server 用 @Tool 暴露"这种情况,annotations() 就是 null。漏掉这个分支的结果是内部工具全被过滤掉,或者反过来全被放进来。

至于幂等,idempotentHint 不能替代真幂等。写操作我一律加一个 requestId 参数,由适配层落一张去重表;模型重试时可能换值,所以真正的键是业务键 (订单号 + 操作类型 + 航段),requestId 只用于把链路对上。


5. 传输选型:默认值早就变了

第九篇留下的"SSE 从 2.0.0 起 deprecated、推荐 Streamable HTTP"这条结论,现在有了 jar 级别的证据。

spring-configuration-metadata.json(spring-ai-autoconfigure-mcp-server-common:2.0.1)里这一行是关键:

text 复制代码
spring.ai.mcp.server.protocol = McpServerProperties$ServerProtocol, default = streamable
枚举取值:SSE / STREAMABLE / STATELESS

也就是说 2.0.1 不再默认走 SSE ,同时 sse-endpoint=/sse、sse-message-endpoint=/mcp/message 这些老键还在------兼容留着,默认已换。三种协议的取舍:

协议 什么时候选 代价
STREAMABLE(默认) 只要是 HTTP 暴露,就选它 需要会话保持;DELETE 语义要确认
SSE 对端是很老的客户端,不支持 Streamable HTTP 双通道、断线重连靠 Last-Event-ID,鉴权钩子也没得用(5.1)
STATELESS 工具纯只读、无进度、无服务端主动通知 没有 tools/list_changed 之类的会话能力
stdio(spring.ai.mcp.server.stdio=true) 本地桌面客户端、和宿主同进程生命周期 一个进程一个客户端,不能多租户

服务端配置全景(我把这个 jar 的元数据全 dump 了一遍,下面是实际用到的):

键 默认 说明
spring.ai.mcp.server.enabled true 总开关,false 时整套不初始化
spring.ai.mcp.server.protocol streamable 上面那个枚举
spring.ai.mcp.server.type sync SYNC / ASYNC,决定注册哪套 specification bean
spring.ai.mcp.server.name / version mcp-server / 1.0.0 会进 initialize 的 serverInfo,默认前缀工具名要改
spring.ai.mcp.server.instructions 空 给客户端 LLM 的服务器级使用说明
spring.ai.mcp.server.request-timeout 20s 超时;老接口 P99 超过 20s 的话必须调,否则协议层先断
spring.ai.mcp.server.streamable-http.mcp-endpoint /mcp 端点
spring.ai.mcp.server.streamable-http.keep-alive-interval 未设 跨网关部署建议开
spring.ai.mcp.server.streamable-http.disallow-delete false 网关吃掉 DELETE 时设 true,避免会话泄漏
spring.ai.mcp.server.capabilities.tool/prompt/resource/completion 全 true 用不到 prompt 就关掉,capabilities 是对外承诺
spring.ai.mcp.server.tool-change-notification true 配合 McpToolsChangedEvent
spring.ai.mcp.server.tool-response-mime-type.<tool> 空 3.3 的图片返回
spring.ai.mcp.server.expose-mcp-client-tools false 网关模式开关(7.4)
spring.ai.mcp.server.annotation-scanner.enabled true 关掉则 @McpTool 不生效
spring.ai.mcp.server.tool-callback-converter true 关掉则路径 A 不生效;这个键只存在于源码条件里,元数据里没有,IDE 不补全

最后那一行值得多说一句:tool-callback-converter 是 ToolCallbackConverterAutoConfiguration.ToolCallbackConverterCondition 里的 @ConditionalOnProperty(name = "tool-callback-converter"),但它没被写进 spring-configuration-metadata.json。所以你在配置文件里搜不到、补全不出来,删掉它也无从判断生效没有。内部平台要禁止"业务侧随手 @Tool 就对外暴露",就把这个键设成 false,然后在文档里单独写明。

5.1 鉴权头:配置文件里根本放不下

这是批量接入时最容易卡住的一处。客户端的连接参数是 record,字段就两个:

java 复制代码
public record ConnectionParameters(String url, String endpoint) { }   // streamable-http
public record SseParameters(String url, String sseEndpoint) { }        // sse

spring.ai.mcp.client.streamable-http.connections.<name>.* 没有任何 header 字段,所以"在 yml 里配 Authorization"这条路是走不通的。正确姿势是注册一个 bean,SDK 提供:

java 复制代码
public interface McpSyncHttpClientRequestCustomizer {
    void customize(HttpRequest.Builder builder, String method, URI endpoint,
                   String body, McpTransportContext context);
}
java 复制代码
@Bean
McpSyncHttpClientRequestCustomizer bearerAuth(@Value("${mcp.airline.token}") String token) {
    return (builder, method, endpoint, body, context) ->
            builder.header("Authorization", "Bearer " + token);
}

而它只被 Streamable HTTP 的自动配置消费------StreamableHttpHttpClientTransportAutoConfiguration.streamableHttpHttpClientTransports(...) 的形参里有 ObjectProvider<McpSyncHttpClientRequestCustomizer>;SseHttpClientTransportAutoConfiguration.sseHttpClientTransports(...) 的形参里没有 ,只有 McpClientCustomizer<HttpClientSseClientTransport.Builder>。

text 复制代码
要按请求注入鉴权头 --> 只能用 Streamable HTTP
坚持用 SSE        --> 得自己接管 transport builder,或者把 token 塞进 URL(不要这么做)

这算是"SSE 退场"的一个很实际的注脚,不是玄学的性能问题。


6. 身份与上下文:_meta 这条通道和断掉的那一环

第十八篇的结论"身份只能走 ToolContext",到了 MCP 边界上只完成了一半。

6.1 客户端方向:ToolContext 会全量进 _meta

SyncMcpToolCallback 调用时:

java 复制代码
var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null;
var request = CallToolRequest.builder(this.tool.name()).arguments(arguments).meta(mcpMeta).build();
response = this.mcpClient.callTool(request);

defaultConverter() 的行为是把 ToolContext 除 exchange 键和 null 值以外全量拷进 _meta 。注意这句话说的是"全量"------userId、tenantId、abtestGroup、以及任何你塞进去的东西,都会出现在发给远端服务器的报文里。所以两个动作必须提前做:

  • 往 ToolContext 里放字段时要分级,别把调试上下文塞进同一个 map,它们会一起出网。
  • 不想传就把 converter 换成 noOp()(builder 上 toolContextToMcpMetaConverter(...) 可替换,或者按连接做定制)。

6.2 服务端方向:业务 ToolContext 是断的

服务端收到调用后,路径 A 的自动转换只塞一个键:

java 复制代码
String callResult = toolCallback.call(jsonHelper.toJson(request.arguments()),
        new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchangeOrContext)));

TOOL_CONTEXT_MCP_EXCHANGE_KEY 常量值是 "exchange"。也就是说:_meta 里带着 userId 到了你的服务器,但被包成工具的老方法拿不到它 ------它看到的 ToolContext 只有一个 exchange。如果老方法声明了 ToolContext 参数,第十八篇讲的 validateToolContextSupport 不会报错(因为确实传了),只是内容不是你想要的。这才是最阴的地方:不报错,静默降级成"没有身份"。

6.3 修法:路径 C 把 _meta 接回 ToolContext

java 复制代码
@Bean
List<McpServerFeatures.SyncToolSpecification> mcpToolsWithIdentity(
        FlightOrderToolAdapter adapter, McpJsonMapper jsonMapper) {

    var tool = McpSchema.Tool.builder()
            .name("flight_order_query")
            .title("机票订单查询")
            .description("按机票订单号查询订单状态、航段与乘机人列表。只读,不产生任何变更。")
            .inputSchema(INPUT_SCHEMA)                      // Map<String,Object>
            .annotations(McpSchema.ToolAnnotations.builder()
                    .readOnlyHint(true).destructiveHint(false)
                    .idempotentHint(true).openWorldHint(false).build())
            .build();

    return List.of(new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> {
        Map<String, Object> meta = request.meta() == null ? Map.of() : request.meta();
        String userId = Objects.toString(meta.get("userId"), null);
        if (userId == null) {
            return errorResult("缺少调用方身份,已拒绝访问。");     // 硬失败,别让模型猜
        }
        var scoped = adapter.queryWithScope(request.arguments(), Identity.of(userId));
        return textResult(jsonMapper, scoped);
    }));
}

要点三条:身份缺失要在协议边界上硬失败 (这条和第十八篇 ToolContext is required by the method as an argument 的思路一致);meta 的键名是两边约定,必须写进接口文档并纳入评审;越权判断仍然在业务层,MCP 层只负责把身份带到。

另外 McpToolUtils.getMcpExchange(ToolContext) 内部是 (McpSyncServerExchange) context.get("exchange") 的强制转换 。STATELESS 协议下传进来的其实是 McpTransportContext 而不是 exchange(SharedSyncToolSpecification 的 handler 形参类型是 Object),所以这条工具方法在 stateless 部署下取 exchange 有抛 ClassCastException 的风险。要用 exchange 就别选 stateless,或者自己判类型。(这一点我只在源码层看到类型不一致,没跑端到端,见文末说明。)

6.4 反向通道:MCP 其实给了跨进程的 HITL 原语

第十八篇的人工确认是单进程的(toolExecutionEligibilityChecker)。跨进程时,McpSyncServerExchange 上有两个方法:

text 复制代码
exchange.createElicitation(req)  --> 服务端反过来问调用方要一个输入/一次确认 --> ElicitResult(action, content)
exchange.createMessage(req)      --> 服务端借调用方的模型做一次生成(sampling)
exchange.progressNotification(..)/loggingNotification(..) --> 长任务进度与日志回推

客户端侧要声明能接,2.0.1 的接线点已经存在:McpClientAutoConfiguration.mcpSyncClients(...) 会注入一个 ClientMcpSyncHandlersRegistry,它暴露 handleElicitation(connectionName, request)、handleSampling(...)、getCapabilities(connectionName) 等,业务侧用 @McpElicitation / @McpSampling 注解方法即可被登记进去。

text 复制代码
B+ 档(退票)跨进程的正确形状:
   用户 --> 宿主 Agent --> MCP Server(退票工具)
                              |--> createElicitation("确认退票 888...,预计退款 320 元?")
   用户 <-- 宿主 Agent 弹确认 <--  handleElicitation 返回 action=accept + content

这比"自己造一套确认消息表"要划算,因为它天然是协议一部分,任何合规的 MCP 客户端都能接。但它依赖对端实现 elicitation------上生产前先跟调用方确认,否则请求会挂到超时(我这边没能覆盖所有客户端的实测,见文末说明)。

Q1:那 MCP 的鉴权(第九篇讲的 token)和这个是一回事吗?

不是,两层:

层 回答的问题 载体 断了会怎样
传输鉴权 这个 MCP 客户端有没有资格连我 HTTP token / mTLS(5.1 的 customizer) 连不上,401 明显
身份传递 这次工具调用代表哪个业务用户 CallToolRequest._meta 静默,工具拿到空身份或伪造身份

第二层因为没有 401 这种天然反馈,必须在服务端自己写死"取不到身份就拒绝"。


7. 客户端注册、批量治理与上线自检

反过来,当你接入别人的 MCP Server(或者把自己的 Server 交给别人接)时,配置长这样。

7.1 starter 矩阵

BOM 里 MCP 相关 starter 一共五个(我按 spring-ai-bom:2.0.1 的 artifactId 列表核对):

starter 传递进来的关键依赖 用途
spring-ai-starter-mcp-server spring-boot-starter + autoconfigure-server-common + spring-ai-mcp + spring-ai-mcp-annotations stdio,本地/桌面客户端
spring-ai-starter-mcp-server-webmvc spring-boot-starter-web + autoconfigure-server-webmvc + mcp-spring-webmvc Servlet 栈暴露 HTTP
spring-ai-starter-mcp-server-webflux WebFlux 变体 响应式栈
spring-ai-starter-mcp-client autoconfigure-client-common + client-httpclient JDK HttpClient 接入
spring-ai-starter-mcp-client-webflux client-webflux 响应式接入

注意 server 侧三个 starter 都带 spring-ai-mcp-annotations,所以 @McpTool 开箱可用,不用额外加依赖。

7.2 客户端配置全量键

text 复制代码
spring.ai.mcp.client.enabled              = true
spring.ai.mcp.client.name / version       = spring-ai-mcp-client / 1.0.0
spring.ai.mcp.client.type                 = sync          (sync / async)
spring.ai.mcp.client.initialized          = true
spring.ai.mcp.client.request-timeout      = 20s
spring.ai.mcp.client.root-change-notification = true
spring.ai.mcp.client.toolcallback.enabled = true
spring.ai.mcp.client.stdio.connections.<name>.{command,args,env}
spring.ai.mcp.client.sse.connections.<name>.{url,sse-endpoint}
spring.ai.mcp.client.streamable-http.connections.<name>.{url,endpoint}

toolcallback.enabled=true 意味着 MCP 工具会自动变成 ToolCallbackProvider bean 被 ChatClient 拾取------内部平台关掉它,改成显式 provider bean(7.3),否则一次配置就无条件把所有远端工具挂进每一轮对话。

7.3 工具名与重名

客户端侧工具名会带前缀,默认由 DefaultMcpToolNamePrefixGenerator 生成(基于 serverInfo 的 name / version,McpToolUtils.prefixedToolName(...))。两个可选项:McpToolNamePrefixGenerator.noPrefix(),或自定义实现 prefixedToolName(McpConnectionInfo, Tool)。

text 复制代码
两个上游都提供 order_query
  带前缀(默认): airline_mcp_order_query / crm_mcp_order_query   --> 模型看到两个,靠描述分辨
  noPrefix():     重名,行为不确定                                  --> 别用

我的选择是保留默认前缀,同时把 serverInfo 的 name 改成稳定的短名 (默认是 mcp-server,两个服务会同名)。这一步不做,后面接第二个服务时才发现问题已经太晚。

7.4 网关模式与其回环风险

内部如果做"MCP 网关"(一个 Server 聚合多个下游 MCP 能力再暴露),要把 spring.ai.mcp.server.expose-mcp-client-tools=true。默认 false 时你会在启动日志看到这条原文:

text 复制代码
Found MCP Clients. The MCP Client tools will not be exposed by the MCP Server. If you would
like to expose the tools, set spring.ai.mcp.server.expose-mcp-client-tools=true.

框架默认挡这一层是有道理的:ToolCallbackUtils.isMcpToolCallback / isMcpToolProvider 会把来自 MCP 客户端的 callback 过滤掉,防止 A→B→A 的工具回环。真要开网关,就得自己在 McpToolFilter 里做准入,否则两个团队互为上下游时工具列表会互相膨胀。

7.5 分批与自检

灰度顺序我按"协议 → 只读 → 写"三步:先只放 tools/list 不放 tools/call(验连接与鉴权),再放 A 档只读,最后放 B 档并开确认链路。工具数量按批扩,每批不超过 5 个,每批改完重跑第 3 节描述对应的选择测试。

上线自检 12 项:

# 检查 不通过的表现
1 每个工具的 name 有系统前缀且冻结 改名导致调用方与评测集失效
2 无同名工具(跨 provider) 静默去重,只少不多
3 写工具显式声明 5 个 hint 被客户端策略整批藏掉
4 只读工具出参已脱敏 证件号/手机号经 _meta 与 content 出网
5 适配层 catch 异常,返回可读文案 JDBC 异常原文给到外部
6 ToolContext → _meta 字段清单已评审 调试上下文随请求出网
7 服务端"取不到身份即拒绝"已生效 空身份静默继续
8 request-timeout 大于依赖接口 P99 协议层先超时,业务侧不知情
9 鉴权头经 McpSyncHttpClientRequestCustomizer 注入 token 配在 yml 里根本不生效
10 serverInfo name/version 已改 多上游时前缀冲突
11 tools/list 有 CI 快照比对 工具数量悄悄变化
12 写工具配了幂等业务键 + 确认链路 模型重试造成重复扣费

最后总结

  • 32 个存量接口最后包成 6 个工具,盘点分级比任何编码技巧都值钱;判据是四条:读写性、幂等性、副作用范围、用户会不会这么说话。
  • 三条注册路径要按需要选:@Tool 复用(省事,但没有 hints、异常原文会出去)、@McpTool(有 hints,注意 destructiveHint 默认 true 和两个同名注解包的坑)、手写 SyncToolSpecification(唯一能读 _meta 的路径)。
  • description 是唯一的选用依据 ,四段式(做什么 / 何时用 / 何时别用 / 副作用)里"何时别用"最值钱。@ToolParam 的 required 默认 true、description 默认 "no description",不显式写就是给模型猜。
  • OpenAPI 能自动出 schema 骨架,出不了业务语义;2.0.1 也没有任何现成 artifact,别指望"接口一改工具就自动更新"。
  • hints 是声明不是强制:服务端用档位映射表约束自己,客户端用 McpToolFilter 兜住外部,annotations() 为 null 是常态,必须单独分支。
  • 传输默认已经是 streamable,而且只有它接 McpSyncHttpClientRequestCustomizer------要按请求注入鉴权头就别选 SSE。
  • 跨进程的身份链路默认断在 Map.of("exchange", ...) 这一行:_meta 送到服务器了,业务 ToolContext 拿不到。修法只有手写 spec 读 request.meta(),并在边界上"无身份即拒绝"。
  • 对后端 / 架构读者,我最想留两句:工具列表要有 CI 快照 (重名静默去重、toolcallback.enabled 默认全量挂载,这两件事都只让问题延后爆发);_meta 要当出网报文对待(默认转换器是全量拷贝)。

参考资料 & 致谢

1 Spring AI Reference(2.0.1 官方文档)

2 Spring AI - Tools

3 spring-projects/spring-ai - GitHub

4 spring-ai-bom 2.0.1 - Maven Central

5 spring-ai-mcp 2.0.1(含 sources jar)- Maven Central

6 io.modelcontextprotocol.sdk:mcp 2.0.0 - Maven Central

7 org.springaicommunity:mcp-annotations 0.9.0 - Maven Central

8 Spring AI 第八篇:Tools / function-call 七大痛点

9 Spring AI 第九篇:MCP 实现、原理与鉴权

10 Spring AI 第十一篇:基于航空智能客服的 RAG 实战

11 Spring AI 第十三篇:给 AI 应用装上仪表盘

相关推荐
晓天衡宇•评测社区1 小时前
晓天衡宇亮相 2026 云栖大会:迈向更可信的高质量评测
人工智能·算法·机器学习
Zhou1411361 小时前
SpringBoot_02_自动配置原理
java·spring boot·后端
FYKJ_20101 小时前
SSM校园失物招领系统41452-计算机课程设计、毕业设计
java·vue.js·spring boot·python·mysql·typescript·spark
kv1102 小时前
Android studio国内开发有关
java·数据库·android studio
点纭2 小时前
LLM理论:RAG基础
人工智能
生活愉甜2 小时前
今年iRTE2026,值得去吗?
人工智能
ksueh2 小时前
平台围剿AI网文能持久吗:更新量指标一日不改AI一日停不下来
人工智能·ai写作·ai工具·ai写小说
智商网输送线配件2 小时前
输送机及配件采购实战:东莞中小制造企业2026年供应平台选型技术指南
人工智能·制造·智商网·流水线设备配件
摹客2 小时前
【趋势】AI重构原型设计:从表达文件到验证行为,5个变化+工具选型
人工智能·microsoft·产品经理