💡 第九篇讲的是"怎么起一个 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 节的档位把三档写死成一张映射表,评审时逐工具核对,别让提交人自由发挥。
| 档位 | readOnly | destructive | idempotent | openWorld | 服务端额外要求 |
|---|---|---|---|---|---|
| A 只读 | true |
false |
true |
false |
出参脱敏 |
| B 写(可补偿) | false |
false |
false |
false |
必填 requestId 幂等键 |
| B+ 资金 / 不可撤销 | false |
true |
false |
false |
人工确认(第 6.4 节) |
| 出网调用外部 | 视情况 | 视情况 | 视情况 | true |
沙箱或只读凭证 |
- 客户端 :用
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 官方文档)
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 七大痛点
