第十四章 · 通商口岸:MCP 集成
下一章卷七 · 邦交与开馆(MCP + ⾃动配置 + 架构)
第⼗四章 · 通商⼝岸:MCP 集成"我这边有个差役叫 get_weather ,你那边能直接派吗?" "能。报上名号,我替你转⼀道⽂书过去。" ——这就是两座译馆之间,最朴素的⼀笔邦交。
前⾯⼗三章,我们⼀直在⼀座译馆⾥转悠:前台掌柜怎么接待客官、驿丞怎么层层盘查、藏经阁怎么调阅典籍、差役调度房怎么派差办差。所有这些,都发⽣在同⼀座馆⾥——你的应⽤进程⾥。
但天下不⽌⼀座译馆。
隔壁可能有⼀座专管⽂件系统的⼩馆,街那头有座专管数据库的,海外还有座 GitHub 开的馆。它们各⾃养着⼀批差役(⼯具),都很能⼲。问题来了:你这座馆⾥的模型,能不能直接差遣别家馆的差役? 反过来,你养的得意差役,能不能挂牌出去,让别家的模型也来差遣?
这就是 MCP(Model Context Protocol)要解决的事。Spring AI 把它叫做"⼀等公⺠"( README.md:54 ),⽽在我们的译馆隐喻⾥,它是通商⼝岸——两座译馆互认差役、互通⽂书的邦交协议。底层全部架在官⽅的 MCP Java SDK( io.modelcontextprotocol.* )之上,Spring AI 只做⼀件事:在 MCP 的世界与 Spring AI的世界之间,架⼀座双向的桥。
这⼀章,我们就站在⼝岸上,看货物怎么进、怎么出。
⼀、进⼝:把别家的差役,认领成⾃⼰的先看"进⼝"⽅向——你是 MCP 客户端,要消费别家 MCP server 暴露的⼯具。
别家的⼯具,在 MCP 世界⾥是⼀个 io.modelcontextprotocol.spec.McpSchema.Tool ,有 name、description、inputSchema。可你这座译馆的差役调度房只认ToolCallback (⻅第⼗⼀章)。语⾔不通,得有⼈当翻译。这个翻译官就是 SyncMcpToolCallback ——它实现 ToolCallback ,把⼀个 MCP Tool 包装成⼀个你这边的"差役"。
最值得盯着看的,是它真正派差时那段:
// mcp/common/.../SyncMcpToolCallback.java:111-137@Override
public String call(String toolCallInput, @Nullable ToolContext toolContext) {// Handle the possible null parameter situation in streaming mode.
if (!StringUtils.hasText(toolCallInput)) {// ...流式⾥参数可能为 null,兜底成 "{}"
toolCallInput = "{}";
}
Map<String, Object> arguments = jsonHelper.fromJsonToMap(toolCallInput);
CallToolResult response;
try {
var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null;
var request = CallToolRequest.builder()// Use the original tool name, not the prefixed one from getToolDefinition
.name(this.tool.name())
.arguments(arguments).meta(mcpMeta)
.build();
response = this.mcpClient.callTool(request);
}// ...
}这⾥藏着本章第⼀个必须看懂的细节:发请求时⽤的是 this.tool.name() ——原始⼯具名,⽽不是 getToolDefinition() 暴露出去的那个前缀名。源码⾥连着两
处注释反复强调这件事( SyncMcpToolCallback.java:129 、 :135 )。为什么要分两个名字?因为译馆对内、对外⽤的是两套户籍:
对内(给本馆模型看的 ToolDefinition ):⽤前缀名,⽐如 weather_server_get_weather ,防⽌你同时连了三家 server、三家都有个叫 query 的⼯具时撞名。
对外(给⽬标 server 发的 CallToolRequest ):必须⽤⼈家⾃⼰的原名 get_weather ,因为对⾯那座馆的户籍册上,根本没有"前缀"这回事。
🦴 ⻣灰级细节: _meta 这条暗线。 ToolContext 是 Spring AI ⼯具调⽤时携带的"随⾏公⽂"(⽤户身份、租户、追踪 id 之类)。MCP 协议⾥有个对应的 _meta
字段。 SyncMcpToolCallback 给了你⼀个 ToolContextToMcpMetaConverter ,把本馆的 ToolContext 翻译成 MCP 的 _meta ,随 CallToolRequest ⼀起捎过去( :126 、 :132 )。这意味着跨馆派差时,上下⽂能透传过境——这是很多⼈压根没注意到的⼀条管道。注意它对 null 很克制: toolContext 为空就不转,转出来是 null 就不塞,绝不⽆中⽣有。
回执的处理也⼲净:对⾯返回的 CallToolResult 若 isError 为真,统⼀抛 ToolExecutionException ( :144-150 ),否则把 content 序列化回 JSON 字符串交还给调度房( :151 )。整条链路:JSON 进 → Map → CallToolRequest → 过境 → CallToolResult → JSON 出,⾸尾都是字符串,完美嵌进 Spring AI 既有的⼯具回路,模型那头根本感觉不到这个差役其实住在隔壁。
⼆、海关总署: SyncMcpToolCallbackProvider⼀个 SyncMcpToolCallback 只认领⼀个⼯具。可你往往连了好⼏家 server,每家⼜有⼀堆⼯具。把它们⼀次性发现、汇总、登记造册的,是SyncMcpToolCallbackProvider ——⼝岸的海关总署。
它⼲三件事:跨多个 client 发现⼯具、加缓存防抖、监听变更动态刷新。先看发现与缓存:
// mcp/common/.../SyncMcpToolCallbackProvider.java:124-155@Override
public ToolCallback[] getToolCallbacks() {
if (this.invalidateCache) {
this.lock.lock();
try {
if (this.invalidateCache) {
this.cachedToolCallbacks = this.mcpClients.stream()
.flatMap(mcpClient -> mcpClient.listTools()
.tools()
.stream()
.filter(tool -> this.toolFilter.test(connectionInfo(mcpClient), tool))
.<ToolCallback>map(tool -> SyncMcpToolCallback.builder()
.mcpClient(mcpClient)
.tool(tool)
.prefixedToolName(
this.toolNamePrefixGenerator.prefixedToolName(connectionInfo(mcpClient), tool))
.toolContextToMcpMetaConverter(this.toolContextToMcpMetaConverter)
.build()))
.toList();
this.validateToolCallbacks(this.cachedToolCallbacks);
this.invalidateCache = false;
}
}
finally {
this.lock.unlock();
}
}
return this.cachedToolCallbacks.toArray(new ToolCallback[0]);
}这是⼀段教科书级的双重检查锁(double-checked locking): invalidateCache 是 volatile ,先⽆锁读⼀次,真要重建才进锁,进锁后再读⼀次确认——避免多个请求线程同时去 listTools() 把对⾯ server 打爆。每个 tool 在这⾥被装上前缀名、装上 meta 转换器,造成 SyncMcpToolCallback 。造完还顺⼿
validateToolCallbacks 查⼀遍重名( :182-188 ),⼀旦发现两个⼯具撞名,直接抛 IllegalStateException("Multiple tools with the same name ...")——宁可启动失败,也不让两个同名差役混进同⼀个名录,引发模型派差时的歧义。
真正让这套机制"活"起来的,是它还是个 ApplicationListener<McpToolsChangedEvent> :// mcp/common/.../SyncMcpToolCallbackProvider.java:164-167@Override
public void onApplicationEvent(McpToolsChangedEvent event) {
this.invalidateCache();
}✨ 爽点: tools/list_changed 的动态热插拔。 MCP 协议⾥,server 可以主动⼴播⼀条 "tools/list_changed"——"我这边⼯具名录变了"。客户端收到后,Spring AI
把它转成⼀个 McpToolsChangedEvent 在容器⾥发布。这个 Provider ⼀听到,只做⼀个轻动作:把 invalidateCache 置回 true 。它不⽴刻重新拉取,⽽是等下
⼀次有⼈调 getToolCallbacks() 时再懒重建。于是隔壁馆临时新招了个差役、或裁掉⼀个,你这座馆⽆需重启,下⼀轮对话就能感知到名录的变化。差役热插拔——这是 MCP ⽐传统"启动时⼀次性注册⼯具"⾼明的地⽅,也是这个事件监听器存在的全部意义。
三、出⼝:把⾃⼰的差役,挂牌给天下反过来,你是 MCP server——你养了⼀批 @Tool / ToolCallback ,想挂出去让别家模型来差遣。这是"出⼝"⽅向,翻译活⼉落在McpToolUtils.toSyncToolSpecification 上:把 Spring AI 的 ToolCallback 反向构造成 MCP 的 SyncToolSpecification 。
// mcp/common/.../McpToolUtils.java:248-281
private static SharedSyncToolSpecification toSharedSyncToolSpecification(ToolCallback toolCallback,
@Nullable MimeType mimeType) {
var tool = McpSchema.Tool.builder()
.name(toolCallback.getToolDefinition().name())
.description(toolCallback.getToolDefinition().description())
.inputSchema(
jsonHelper.fromJson(toolCallback.getToolDefinition().inputSchema(), McpSchema.JsonSchema.class))
.build();
return new SharedSyncToolSpecification(tool, (exchangeOrContext, request) -> {
try {
String callResult = toolCallback.call(jsonHelper.toJson(request.arguments()),
new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchangeOrContext)));
if (mimeType != null && mimeType.toString().startsWith("image")) {// ...返回 ImageContent
}
return McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent(callResult)))
.isError(false)
.build();
}
catch (Exception e) {
return McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent(e.getMessage())))
.isError(true)
.build();
}
});
}
这段最妙的⼀⼿,是那个 handler 闭包⾥的 new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchangeOrContext)) 。MCP 协议⾥,server 处理⼀次调⽤时会拿到⼀个 exchange ——它代表"当前这次会话"(可以⽤它回头向客户端发通知、做 sampling、查 roots 等)。Spring AI 把这
个 exchange 塞进了 ToolContext ,key 就是常量 "exchange" ( McpToolUtils.java:77 )。也就是说:你写的那个普通 @Tool ⽅法,在被外部 MCP 客户端调⽤时,能从 ToolContext ⾥反向掏出 MCP 的 exchange ,从⽽在⼯具内部反向操纵这次 MCP 会话。配套的取出⽅法是 getMcpExchange(ToolContext)
( :288-294 )。⼀个本来"对 MCP ⼀⽆所知"的⼯具,就这样被赋予了感知 MCP 会话的能⼒——⽽它⾃⼰的代码⼀⾏都不⽤改。这是依赖注⼊思想在协议边界上的⼀次漂亮延伸。
🦴 ⻣灰级细节:错误语义在出⼊两个⽅向上是不对称的。 进⼝⽅向( SyncMcpToolCallback.call ),对⾯返回的错误会被抛成 ToolExecutionException ( :144-
150 )。出⼝⽅向(上⾯这段 handler),你的⼯具抛了异常不会向上传播,⽽是被 catch 住、包成⼀个 isError=true 的 CallToolResult 返回给对⾯( :274-279 )。⼀个抛、⼀个吞——这不是写法不统⼀,⽽是协议要求:作为 server,你不能让⼀个⼯具异常把整条 MCP 连接掀翻,必须把错误"信封化"成⼀条正常协议消息递回去。看懂这个不对称,你就看懂了 MCP 的错误模型。
注意 handler 的第⼀个参数叫 exchangeOrContext ⽽⾮ exchange ——因为同⼀份 SharedSyncToolSpecification 既给有状态的
toSyncToolSpecification ⽤( :202-209 ),也给⽆状态的 toStatelessSyncToolSpecification ⽤( :223-232 )。后者对应 Streamable-HTTP 的⽆状态服务端,传进来的是 stateless context ⽽⾮ exchange。⼀份 handler 复⽤两种语境,命名上诚实地点破了这⼀点。
四、户籍科的难题:⼯具名前缀,与中⽂前⾯反复提到"前缀名"。⽣成它的算法藏在 McpToolUtils.prefixedToolName / format ⾥,这是本章⼀个意外有料的⻆落:
// mcp/common/.../McpToolUtils.java:114-122
public static String format(String input) {// Replace any character that isn't alphanumeric, underscore, or hyphen with// concatenation. Support Han script + CJK blocks for complete Chinese character// coverage
String formatted = input
.replaceAll("[^\\p{IsHan}\\p{InCJK_Unified_Ideographs}\\p{InCJK_Compatibility_Ideographs}a-zA-Z0-9_-]", "");
return formatted.replaceAll("-", "_");
}⼯具名要进 JSON Schema、要被各家模型解析,所以得"消毒":凡不是字⺟、数字、下划线、连字符的字符,⼀律删掉,连字符再统⼀成下划线。但这条正则⾥特意放⾏
了三类 Unicode 块: \p{IsHan} (汉字)、 InCJK_Unified_Ideographs 、 InCJK_Compatibility_Ideographs 。换句话说,Spring AI 显式⽀持中⽂⼯具名——你完全可以写⼀个叫"查天⽓"的差役挂出去,前缀算法不会把它的中⽂字符当噪⾳抹掉。注释⾥那句 "complete Chinese character coverage" 不是客套,是真在代码⾥兑现了。
拼接逻辑也讲究( :89-108 ): prefix (客户端名)先经 shorten 取每段⾸字⺟缩写—— Weather_Server 缩成 w_s ( :129-138 ); title 不缩、保留全名;最后接上 format 过的⼯具名。整串若超 64 字符,保留后 64 位( :103-105 )——因为后缀(⼯具原名)⽐前缀更要紧,宁可砍掉前⾯的来源标识,也要保住尾巴上的⼯具身份。
⚠ 暗礁:默认到底加不加前缀,得看你站在哪⼀侧。 这⾥有个极易踩坑的语义错位。
SyncMcpToolCallback.Builder ⾃⼰ build() 时,若不指定前缀名,默认只对⼯具名做 format 处理、不加来源前缀( SyncMcpToolCallback.java:228-
230 )。
⽽⾛ Spring Boot ⾃动配置那条路时, McpToolCallbackAutoConfiguration 注册了⼀个 DefaultMcpToolNamePrefixGenerator 的 Bean( :52-54 ),Provider
会⽤它真的加上前缀。但同⼀个⽅法⾥给 toolNamePrefixGenerator 的 getIfUnique 兜底值却是 McpToolNamePrefixGenerator.noPrefix() ( :81-82 )——⼀个"不加前缀"的退路。
于是结论很绕:⾃动配置环境下,因为那个 @Bean 抢先占了坑,默认⾏为是"加前缀";只有当你⼿动把那个 Bean 顶掉、⼜没给替代品时,才会退回到 noPrefix 。 这种"Bean 默认值"和"getIfUnique 兜底值"不⼀致的写法,是 belt-and-suspenders(双保险),但也实打实地给排查 "为什么我的⼯具名⼀会⼉带前缀⼀会⼉不带" 制造了认知负担。读这段代码时,务必分清你⾛的是⼿搓 Builder 还是 autoconfig——两条路的默认⼈格不⼀样。
五、注解层与 Boot 装配:让⼀切⾃动起来到这⾥,桥的两个桥墩(client 适配、server 转换)都⽴好了。剩下的是怎么让开发者少写胶⽔。
第⼀层是注解。 @McpTool 标在⽅法上,就声明了⼀个 MCP ⼯具( mcp/mcp-annotations/.../annotation/McpTool.java:37-121 ),带
name/description/title/generateOutputSchema/metaProvider ,还有⼀组嵌套的⾏为 hint—— readOnlyHint (只读)、 destructiveHint (破坏性,默认
true )、 idempotentHint (幂等)、 openWorldHint (开放世界)。这些 hint 是给客户端的善意提示,注释⾥写得很坦⽩:"They are not guaranteed... Clients should
never make tool use decisions based on ToolAnnotations received from untrusted servers"( :77-82 )——别信不可信 server 报上来的 hint。⼀句协议级的安全告诫,被⽼实地抄进了 Javadoc。 provider/tool/AbstractMcpToolProvider ⽤反射扫描这些⽅法,转成 MCP Spec。
第⼆层是 Boot ⾃动配置,这才是 MCP "⼀等公⺠" 待遇的兑现处。server 端的 AutoConfiguration.imports ⼀⼝⽓列了 8 个配置类( spring-ai-
autoconfigure-mcp-server-common ),挑三个看:McpServerAutoConfiguration :按你声明的 capabilities 逐项装配 server。它的 mcpSyncServer Bean 是典型——拿
ObjectProvider<List<SyncToolSpecification>> 把容器⾥所有⼯具规范收上来,只有 capabilities.isTool() 为真才真正注册
( McpServerAutoConfiguration.java:132-144 );resources/prompts/completions 同理,缺什么能⼒就跳过什么( :147-199 )。transport 是
McpStreamableServerTransportProvider 时⾛ Streamable 分⽀,否则⾛普通分⽀( :123-128 )。
ToolCallbackConverterAutoConfiguration :把你应⽤⾥所有 ToolCallback / ToolCallbackProvider Bean ⾃动转成 MCP Spec( :49-77 )。它有个按⼯具名去重的细节,值得⼀记:
// auto-configurations/.../ToolCallbackConverterAutoConfiguration.java:64-69
return tools.stream() // Key: tool name
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool,
(existing, replacement) -> existing)) // On duplicate key, keep the existing tool
.values()// ...撞名时保留先到的那个( existing ),后来者被静默丢弃。这⼀步产出的 List<SyncToolSpecification> ,正好被上⾯ mcpSyncServer 那个 ObjectProvider收⾛——⼀进⼀出,闭环咬合。
McpToolCallbackAutoConfiguration (client 端):把容器⾥的 McpSyncClient 们包成 SyncMcpToolCallbackProvider ( :68-
86 ), toolFilter / prefixGenerator / metaConverter 全⽤ getIfUnique(默认值) 软接⼊——你想定制就放个 Bean,不想管就吃默认。⚠ 暗礁:transport 矩阵的复杂度,是这套设计真实的代价。 别被"⼀等公⺠"四个字哄住。MCP 的传输层(transport)被切成了⼀张相当密的包矩阵:客户端有
httpclient (基于 JDK HttpClient )和 webflux (基于 WebClient )两套,每套⾥⼜各有 SSE 和 Streamable-HTTP 两种 transport 的 autoconfig;服务端有webmvc 和 webflux 两套,每套⾥⼜各有 SSE / Streamable / Stateless 三个变体。再叠上 sync/async 两种 API ⻛格——光是排列组合就够⼈头⼤。协议枚举
ServerProtocol{SSE, STREAMABLE, STATELESS} 默认值是 STREAMABLE ( McpServerProperties.java:100 、 :138 ),不是很多⼈以为的 SSE。这是个会咬⼈的默认:你照着⼀篇⽼教程配了 SSE 端点,却发现 server 默认起的是 Streamable-HTTP,两边对不上。这套矩阵的灵活性是真的,但"上⼿即正确"这件事,在 MCP 这⼀块,Spring AI 还没完全做到。⽂档与默认值之间的落差,是当前最容易让⼈栽跟头的地⽅。
六、回望这座⼝岸把整章拉远看,MCP 集成的⻣架其实只有⼀句话:⼀座双向桥,两端各⼀个适配器,中间靠 MCP Java SDK ⾛线。
进⼝侧: SyncMcpToolCallback 把 MCP Tool 伪装成本馆 ToolCallback ,发请求时⽤原名、对内⽤前缀名、顺带透传
_meta ; SyncMcpToolCallbackProvider 跨多 client 发现、加双检锁缓存、靠 McpToolsChangedEvent 实现热刷新。出⼝侧: McpToolUtils.toSyncToolSpecification 把本馆 ToolCallback 反向做成 MCP Spec,并把 exchange 注⼊ ToolContext ,让普通⼯具也能感知 MCP 会话;错误在这⼀侧被"信封化"⽽⾮抛出。
上⾯再罩⼀层注解( @McpTool )和⼀摞 autoconfig,把胶⽔降到最少。
这是个设计上很⼲净的双向桥。真正的复杂度不在桥本身,⽽在桥下那条河——transport 矩阵,以及前缀/中⽂/默认协议这些边⻆的语义。看懂了这⼀章,你就拿到了"译馆与译馆做⽣意"的通关⽂牒。
🎯 三句带⾛
1. MCP 是双向桥: SyncMcpToolCallback (client)把 MCP Tool 适配成 ToolCallback 、调⽤时⽤原始名发
CallToolRequest ; McpToolUtils.toSyncToolSpecification (server)反向把 ToolCallback 转成 MCP Spec 并把 exchange 注⼊ToolContext 。
2. list_changed 热刷新靠 SyncMcpToolCallbackProvider 监听 McpToolsChangedEvent → invalidateCache() → 下次 getToolCallbacks() 双检锁懒重建,⼯具可热插拔⽆需重启。
3. 三个易踩的默认:⼯具名前缀在 autoconfig 下默认加( DefaultMcpToolNamePrefixGenerator Bean)⽽⼿搓 Builder 默认不加;⼯具名前缀算法显式⽀持中⽂
(Han/CJK);server 默认协议是 STREAMABLE 不是 SSE。承上启下这⼀章我们反复借⼒⼀个词——⾃动配置。 McpServerAutoConfiguration 按 capabilities 装配、 ToolCallbackConverterAutoConfiguration 去重转Spec、 McpToolCallbackAutoConfiguration 把 client 包成 Provider……这些 @AutoConfiguration 类像⼀群不知疲倦的驿丞,在你毫⽆察觉时就把整座馆张罗停当。可它们到底凭什么知道"该装什么、不该装什么"? @ConditionalOnClass 、 @ConditionalOnProperty 、 ObjectProvider 软依赖、 .imports ⽂件、starter 与 autoconfig 的分层契约——这⼀整套"开箱即起馆"的机关,才是 Spring AI 作为 Spring 家族成员最深的底⾊。下⼀章「第⼗五章 · 开箱起馆:Spring Boot⾃动配置与 starter」,我们就拆开这套驿站套装,看⼀个 starter 是怎么"⼀拉就起⼀座馆"的。