Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
第 11 章在讲
DefaultToolCallingManager时留下过一条 WARN 文案没有展开:
vbnetLLM may have adapted the tool name 'xxx', especially if the name was truncated due to length limits. If this is the case, you can customize the prefixing and processing logic using McpToolNamePrefixGenerator(
spring-ai-model/.../model/tool/DefaultToolCallingManager.java:86-88、:293-294)这条告警同时提到了两件事:名字被截断 ,以及用
McpToolNamePrefixGenerator自定义前缀逻辑。本章把这两个都讲透,顺带把 MCP 客户端的模块拓扑、三种传输、工具聚合与热更新一次说清。
13.0 本章的四句总纲
- 传输层已经换代。 SSE 的整套客户端自动配置在 2.0.0 被标记
@Deprecated(since = "2.0.0", forRemoval = true)(SseHttpClientTransportAutoConfiguration:64、SseWebFluxTransportAutoConfiguration:72、McpSseClientProperties:59),Streamable HTTP 是现在的方向;STDIO 依然是一等公民,且不在废弃名单上。 - MCP 工具被显式排除出
ToolCallbackResolver。ToolCallingAutoConfiguration用isMcpToolCallbackProvider按类名字符串 过滤掉了两个 MCP provider(:96-99、:102-108),所以spring.ai.tools.resolution.fallback.enabled=true对 MCP 工具完全无效------必须显式挂载。 DefaultMcpToolNamePrefixGenerator的"幂等"是假的。 它只在(client, server, tool)组合首次出现时 做消歧,之后一律短路返回format(tool.name())。而它没有把首次分配的名字存下来 ,所以重名工具在第二次刷新(工具变更事件触发)后会退化成同名,被validateToolCallbacks直接打成IllegalStateException。- 存在两套互不相干的命名逻辑。
McpToolUtils.prefixedToolName(prefix, title, toolName)(含 64 字符尾部截断)只被已废弃的构造函数 使用;DefaultMcpToolNamePrefixGenerator走的是另一条路(format()+alt_N_计数)。告警里说的"truncated due to length limits"指的是前者。
13.1 模块拓扑与 Starter 选型
13.1.1 源码目录与 artifact 的对应关系
| 源码目录 | artifactId | 内容 |
|---|---|---|
mcp/common |
spring-ai-mcp |
客户端/服务端共用核心:SyncMcpToolCallbackProvider、AsyncMcpToolCallbackProvider、SyncMcpToolCallback、AsyncMcpToolCallback、McpToolNamePrefixGenerator、DefaultMcpToolNamePrefixGenerator、McpToolFilter、McpToolsChangedEvent、McpToolUtils、ToolContextToMcpMetaConverter、McpConnectionInfo |
mcp/mcp-annotations |
spring-ai-mcp-annotations |
@McpTool / @McpArg / @McpToolParam / @McpSampling / @McpElicitation / @McpLogging / @McpProgress / @McpMeta / @McpComplete ... 以及 McpSyncRequestContext / McpAsyncRequestContext(第 14 章展开服务端部分) |
mcp/transport/mcp-spring-webflux |
mcp-spring-webflux |
同时 含客户端传输(WebFluxSseClientTransport、WebClientStreamableHttpTransport)与服务端传输 |
mcp/transport/mcp-spring-webmvc |
mcp-spring-webmvc |
只 含服务端传输(mcp/transport/mcp-spring-webmvc/src/main/java/org/springframework/ai/mcp/server/webmvc/transport) |
注意 mcp/transport/mcp-spring-webmvc 下没有 client 子包------WebMVC 应用想用 MCP 客户端只能走 JDK HttpClient 传输。
13.1.2 自动配置模块
arduino
auto-configurations/mcp/
├── spring-ai-autoconfigure-mcp-client-common/ ← 传输无关
│ ├── McpClientAutoConfiguration 创建 McpSyncClient / McpAsyncClient
│ ├── McpToolCallbackAutoConfiguration 把 client 转成 ToolCallbackProvider
│ ├── StdioTransportAutoConfiguration STDIO 传输
│ ├── McpSseClientConnectionDetails / PropertiesMcpSseClientConnectionDetails
│ ├── NamedClientMcpTransport
│ ├── McpSyncToolsChangeEventEmmiter / McpAsyncToolsChangeEventEmmiter
│ ├── configurer/McpSyncClientConfigurer / McpAsyncClientConfigurer
│ ├── annotations/McpClientAnnotationScannerAutoConfiguration
│ └── properties/ McpClientCommonProperties / McpSseClientProperties /
│ McpStdioClientProperties / McpStreamableHttpClientProperties
├── spring-ai-autoconfigure-mcp-client-httpclient/ ← JDK HttpClient 传输(SSE + Streamable)
└── spring-ai-autoconfigure-mcp-client-webflux/ ← WebClient 传输(SSE + Streamable)
13.1.3 两个 Starter 的差异
xml
<!-- starters/spring-ai-starter-mcp-client/pom.xml -->
<dependency>spring-boot-starter</dependency>
<dependency>spring-ai-autoconfigure-mcp-client-httpclient</dependency>
<dependency>spring-ai-mcp</dependency>
<dependency>spring-ai-mcp-annotations</dependency>
xml
<!-- starters/spring-ai-starter-mcp-client-webflux/pom.xml -->
<dependency>spring-boot-starter</dependency>
<dependency>spring-ai-autoconfigure-mcp-client-webflux</dependency>
<dependency>spring-ai-mcp</dependency>
<dependency>spring-ai-mcp-annotations</dependency>
<dependency>mcp-spring-webflux</dependency>
差异只有传输实现那一层 ,且互斥 :两个自动配置都会产出 List<NamedClientMcpTransport> Bean,如果同时引入,两个 Bean 都会被 McpClientAutoConfiguration 通过 ObjectProvider<List<NamedClientMcpTransport>> 收集到(McpClientAutoConfiguration:155 用 transportsProvider.stream().flatMap(List::stream)),于是同一批连接会被创建两遍客户端(httpclient 一份 + webflux 一份),工具列表随之翻倍。
McpToolCallbackAutoConfiguration 里还有一个 @ConditionalOnMissingBean 的 McpToolNamePrefixGenerator Bean (:50-54),而两个 provider 的 @Bean 方法用的是 getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix())(:80-82、:96-97)。
这里有一个必须注意的"静默降级" :getIfUnique() 在存在多个候选 时返回 null → 走 fallback → 用 noPrefix()。所以如果你自己定义了两个 McpToolNamePrefixGenerator Bean,结果不是"报错",而是静默退回无前缀 ,然后重名工具直接把应用打崩。这是全书反复出现的 ObjectProvider.getIfUnique 陷阱在 MCP 场景的又一次出现。
13.2 三种传输
13.2.1 NamedClientMcpTransport:一切的中心
java
// auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/
// .../McpClientAutoConfiguration/../NamedClientMcpTransport.java:30-32
public record NamedClientMcpTransport(String name, McpClientTransport transport) {
}
一个只有两个字段的 record,却是整个客户端装配的枢纽:所有传输自动配置都产出 List<NamedClientMcpTransport> ,McpClientAutoConfiguration 不关心传输类型,只按名字逐个建客户端。
name 有三个用途:
- 组成
clientInfo的 title(sync 分支)与 name(两个分支); - 作为
McpClientCustomizer.customize(name, ...)的第一个参数------你的自定义器靠它区分"这是哪个 server 的连接"; - 作为
McpToolsChangedEvent.getConnectionName()。
陷阱 :name 来自配置里的 map key(spring.ai.mcp.client.stdio.connections.<name> / ...streamable-http.connections.<name> / ...sse.connections.<name>)。如果你给 STDIO 和 Streamable HTTP 用了同一个 key,会得到两个同名的 transport,进而产生两个同名 client。
13.2.2 STDIO:唯一不依赖 HTTP 栈的传输
java
// StdioTransportAutoConfiguration.java:72-85
@Bean
public List<NamedClientMcpTransport> stdioTransports(McpStdioClientProperties stdioProperties) {
List<NamedClientMcpTransport> stdioTransports = new ArrayList<>();
for (Map.Entry<String, ServerParameters> serverParameters : stdioProperties.toServerParameters().entrySet()) {
var transport = new StdioClientTransport(serverParameters.getValue(),
new JacksonMcpJsonMapper(JsonMapper.shared()));
stdioTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport));
}
return stdioTransports;
}
注意这个 @Bean 没有任何 @ConditionalOnProperty 前缀开关 ------只要 spring.ai.mcp.client.enabled=true(默认),且 spring.ai.mcp.client.stdio.connections 非空,就会建传输。而 McpStdioClientProperties 的 connections 是一个 final Map,默认为空。
toServerParameters() 的两路来源 (McpStdioClientProperties:95-103):
java
public Map<String, ServerParameters> toServerParameters() {
Map<String, ServerParameters> serverParameters = new HashMap<>();
serverParameters.putAll(resourceToServerParameters()); // ① 外部 JSON 资源
for (Map.Entry<String, Parameters> entry : this.connections.entrySet()) {
serverParameters.put(entry.getKey(), entry.getValue().toServerParameters()); // ② YAML
}
return serverParameters;
}
第 ② 路会覆盖 第 ① 路(同名 key 时)。而 resourceToServerParameters() 的实现有一个非常隐蔽的行为:
java
// McpStdioClientProperties:70-93
Map<String, Map<String, Parameters>> stdioConnection = JsonMapper.shared()
.readValue(this.serversConfiguration.getInputStream(), new TypeReference<>() {
});
Map<String, Parameters> mcpServerJsonConfig = stdioConnection.entrySet().iterator().next().getValue();
它只读 JSON 顶层 map 的"第一个" entry,然后用它的 value。也就是说这份 JSON 必须长成:
json
{
"mcpServers": {
"server1": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
"server2": { "command": "docker", "args": ["run", "-i", "mcp/postgres"] }
}
}
顶层那个 key 叫什么无所谓 ("mcpServers" 只是约定),但第二个顶层 key 会被完全忽略------没有日志、没有警告。这是 Claude Desktop 配置格式直接兼容的代价。
Parameters record 只有三个字段:command、args、env。没有工作目录、没有超时、没有 stderr 处理策略 ------这些只能通过 McpClientCustomizer<StdioClientTransport>(如果 SDK 暴露了 builder)或改用程序化构造 ServerParameters 来解决。
STDIO 的两个硬伤:
- 每个连接 fork 一个子进程 。10 个 MCP server = 10 个进程。进程崩溃后客户端不会自动重启(
StdioClientTransport的生命周期由 SDK 管理)。 JsonMapper.shared()是全局共享实例 (:78)。如果你在别处定制了这个 mapper(例如加了自定义 module),STDIO 的 JSON 解析行为会跟着变。
13.2.3 Streamable HTTP:现在的默认方向
两个实现,配置完全对称。
JDK HttpClient 版:
java
// StreamableHttpHttpClientTransportAutoConfiguration.java:107-150
@Bean
public List<NamedClientMcpTransport> streamableHttpHttpClientTransports(
McpStreamableHttpClientProperties streamableProperties, ObjectProvider<JsonMapper> jsonMapperProvider,
ObjectProvider<McpSyncHttpClientRequestCustomizer> syncRequestCustomizers,
ObjectProvider<McpAsyncHttpClientRequestCustomizer> asyncRequestCustomizers,
ObjectProvider<McpClientCustomizer<HttpClientStreamableHttpTransport.Builder>> transportCustomizers) {
JsonMapper jsonMapper = jsonMapperProvider.getIfAvailable(JsonMapper::shared);
List<McpAsyncHttpClientRequestCustomizer> requestCustomizers = requestCustomizers(syncRequestCustomizers,
asyncRequestCustomizers);
List<NamedClientMcpTransport> streamableHttpTransports = new ArrayList<>();
for (Map.Entry<String, ConnectionParameters> serverParameters : streamableProperties.getConnections().entrySet()) {
String name = serverParameters.getKey();
String baseUrl = serverParameters.getValue().url();
String streamableHttpEndpoint = serverParameters.getValue().endpoint() != null
? serverParameters.getValue().endpoint() : "/mcp";
HttpClientStreamableHttpTransport.Builder transportBuilder = HttpClientStreamableHttpTransport
.builder(baseUrl)
.endpoint(streamableHttpEndpoint)
.clientBuilder(HttpClient.newBuilder())
.jsonMapper(new JacksonMcpJsonMapper(jsonMapper));
if (!requestCustomizers.isEmpty()) {
transportBuilder
.asyncHttpRequestCustomizer(new DelegatingMcpAsyncHttpClientRequestCustomizer(requestCustomizers));
}
for (McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> customizer : transportCustomizers) {
customizer.customize(name, transportBuilder);
}
HttpClientStreamableHttpTransport transport = transportBuilder.build();
streamableHttpTransports.add(new NamedClientMcpTransport(name, transport));
}
return streamableHttpTransports;
}
WebFlux 版 (StreamableHttpWebFluxTransportAutoConfiguration.java:91-122)结构相同,差别在:
| 维度 | httpclient | webflux |
|---|---|---|
| HTTP 客户端 | HttpClient.newBuilder()(每次新建) |
webClientBuilderTemplate.clone().baseUrl(url) |
| JsonMapper 兜底 | JsonMapper::shared |
JsonMapper::new |
| 额外自定义器 | McpSyncHttpClientRequestCustomizer / McpAsyncHttpClientRequestCustomizer |
无(靠 WebClient.Builder 的 filter) |
JsonMapper::new 与 JsonMapper::shared 的兜底差异是个真陷阱 :WebFlux 版在没有 JsonMapper Bean 时新建一个空配置 的 mapper,而 JDK 版用全局 shared。如果你依赖某个自定义 module(例如 JavaTimeModule),WebFlux 版会静默丢失它。
clientBuilder(HttpClient.newBuilder()) 每次新建------意味着:
- 每个 MCP 连接一个独立连接池(不共享)。
HttpClient从不关闭 :代码里没有持有引用,也没有注册AutoCloseable。JVM 退出时由 GC 兜底。对长生命周期应用没有实际问题,但无法精细控制连接超时/线程池。- 想加 Authorization header 只能靠
McpSyncHttpClientRequestCustomizer。
请求自定义器的合并顺序 (:156-166):
java
private static List<McpAsyncHttpClientRequestCustomizer> requestCustomizers(
ObjectProvider<McpSyncHttpClientRequestCustomizer> syncRequestCustomizers,
ObjectProvider<McpAsyncHttpClientRequestCustomizer> asyncRequestCustomizers) {
List<McpAsyncHttpClientRequestCustomizer> requestCustomizers = new ArrayList<>();
syncRequestCustomizers.orderedStream()
.map(McpAsyncHttpClientRequestCustomizer::fromSync)
.forEach(requestCustomizers::add);
asyncRequestCustomizers.orderedStream().forEach(requestCustomizers::add);
return requestCustomizers;
}
sync 全部排在 async 之前 ,组内按 @Order。Javadoc 明确写了这一点("Sync customizers are applied before async ones; within each group @Order is honoured")。所以你没法用 @Order 让一个 async 自定义器排在 sync 前面。
同时注意 Javadoc 的另一句(:92-94):
Request customizer beans are installed on the builder before the
McpClientCustomizerbeans run, so a customizer that callsasyncHttpRequestCustomizer(...)itself still takes precedence.
也就是说 McpClientCustomizer 是最后 执行的,它调用 asyncHttpRequestCustomizer(...) 会覆盖掉前面合并好的那一个(而不是追加)。这是一个"最后写入生效"的隐式覆盖,没有 WARN。
13.2.4 SSE:2.0.0 起标记为待移除
三个地方打了 @Deprecated(since = "2.0.0", forRemoval = true):
| 类 | 行 |
|---|---|
SseHttpClientTransportAutoConfiguration |
:64 |
SseWebFluxTransportAutoConfiguration |
:72 |
McpSseClientProperties |
:59 |
功能仍然可用 (只是编译告警),但 McpSseClientProperties 的 Javadoc 一整段都在讲 URL 怎么拆:
yaml
spring.ai.mcp.client.sse:
connections:
mcp-hub:
url: http://localhost:3000
sse-endpoint: /mcp-hub/sse/cf9ec4527e3c4a2cbb149a85ea45ab01
这个"必须手工把 URL 拆成 base + endpoint"的设计,源于 SSE 传输要在同一个 base URL 上发两个不同的请求 (GET 建立事件流、POST 发消息)。Streamable HTTP 沿用了同样的拆分(url + endpoint,默认 /mcp),所以迁移时配置结构几乎是平移。
SSE 独有的一个能力是 McpSseClientConnectionDetails:
java
// McpSseClientConnectionDetails.java:29-33
public interface McpSseClientConnectionDetails extends ConnectionDetails {
Map<String, McpSseClientProperties.SseParameters> getConnections();
}
它 extends org.springframework.boot.autoconfigure.service.connection.ConnectionDetails,所以可以被 Spring Boot 的 ServiceConnection 机制(Docker Compose / Testcontainers)自动注入。两个 SSE 自动配置里都有:
java
@Bean
@ConditionalOnMissingBean(McpSseClientConnectionDetails.class)
PropertiesMcpSseClientConnectionDetails mcpSseClientConnectionDetails(McpSseClientProperties sseProperties) {
return new PropertiesMcpSseClientConnectionDetails(sseProperties);
}
Streamable HTTP 和 STDIO 都没有这个抽象。 也就是说:如果你依赖 Testcontainers 自动拉起 MCP server 并注入连接信息,目前只能走 SSE(已废弃)或自己实现。
13.2.5 三种传输对比与选型
| 维度 | STDIO | Streamable HTTP | SSE(废弃) |
|---|---|---|---|
| 配置前缀 | spring.ai.mcp.client.stdio |
spring.ai.mcp.client.streamable-http |
spring.ai.mcp.client.sse |
| 默认 endpoint | --- | /mcp |
/sse |
| HTTP 栈 | 无(子进程) | JDK HttpClient 或 WebClient |
JDK HttpClient 或 WebClient |
| 连接形态 | 一连接一进程 | 无状态 + 可选会话 | 长连接事件流 |
| ServiceConnection | ❌ | ❌ | ✅ |
| 2.0 状态 | 正常 | 正常 | forRemoval |
| 典型场景 | 本地工具、npx/docker 起的 server | 远程 server、多实例、需要鉴权 | 遗留 |
选型建议:
- 能自己起进程(本地 CLI 工具、官方 npx server)→ STDIO,零网络、零鉴权、零端口占用。
- 远程 server / 需要 header 鉴权 / 需要多实例负载 → Streamable HTTP。
- 新项目不要再用 SSE;已有 SSE 配置迁移成本极低(改前缀 + 改 endpoint 默认值)。
13.3 客户端的创建:McpClientAutoConfiguration
13.3.1 名字的拼接规则
java
// McpClientAutoConfiguration.java:113-115
private String connectedClientName(String clientName, String serverConnectionName) {
return clientName + " - " + serverConnectionName;
}
clientName 默认 "spring-ai-mcp-client"(McpClientCommonProperties:46),serverConnectionName 是 transport 的 name。所以默认得到 spring-ai-mcp-client - server1。
这个名字会进入 clientInfo.name(),并出现在 MCP 握手里,服务端日志里看到的就是它。
13.3.2 一处源码级不对称:sync 有 title,async 没有
java
// McpClientAutoConfiguration.java:160-164 ← SYNC 分支
McpSchema.Implementation clientInfo = McpSchema.Implementation
.builder(this.connectedClientName(commonProperties.getName(), namedTransport.name()),
commonProperties.getVersion())
.title(namedTransport.name()) // ← 有 title
.build();
java
// McpClientAutoConfiguration.java:254-257 ← ASYNC 分支
McpSchema.Implementation clientInfo = McpSchema.Implementation
.builder(this.connectedClientName(commonProperties.getName(), namedTransport.name()),
commonProperties.getVersion())
.build(); // ← 没有 title
这是一个确凿的不对称,而且它真的会影响工具命名。 影响路径是 McpToolUtils.prefixedToolName(prefix, title, toolName)(13.5.4 详述):
java
String input = shorten(format(prefix));
if (!StringUtils.isEmpty(title)) {
input = input + "_" + format(title); // Do not shorten the title.
}
input = input + "_" + format(toolName);
- SYNC :
title = "server1"→ 工具名形如s_a_m_c_s_server1_getWeather。 - ASYNC :
title = null→ 工具名形如s_a_m_c_s_getWeather,丢了 server 名。
而这条路径正是已废弃的 SyncMcpToolCallback(mcpClient, tool) 构造函数使用的:
java
// SyncMcpToolCallback.java:68-71
@Deprecated
public SyncMcpToolCallback(McpSyncClient mcpClient, Tool tool) {
this(mcpClient, tool, McpToolUtils.prefixedToolName(mcpClient.getClientInfo().name(),
mcpClient.getClientInfo().title(), tool.name()), ToolContextToMcpMetaConverter.defaultConverter());
}
结论 :如果你还在用废弃构造函数,或者自定义了调用 McpToolUtils.prefixedToolName 的生成器,那么同一个 MCP server 在 SYNC 和 ASYNC 下会产生不同的工具名 。切换 spring.ai.mcp.client.type 时工具名会变,模型侧缓存的 tool name 随之失效。
(走 DefaultMcpToolNamePrefixGenerator 的路径不受影响------它只读 tool.name()。13.5.3。)
13.3.3 客户端规格的组装顺序
SYNC 分支完整代码(:145-198):
java
@Bean
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
matchIfMissing = true)
public List<McpSyncClient> mcpSyncClients(McpSyncClientConfigurer mcpSyncClientConfigurer,
McpClientCommonProperties commonProperties,
ObjectProvider<List<NamedClientMcpTransport>> transportsProvider,
ObjectProvider<ClientMcpSyncHandlersRegistry> clientMcpSyncHandlersRegistry) {
List<McpSyncClient> mcpSyncClients = new ArrayList<>();
List<NamedClientMcpTransport> namedTransports = transportsProvider.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(namedTransports)) {
for (NamedClientMcpTransport namedTransport : namedTransports) {
McpSchema.Implementation clientInfo = /* 见 13.3.2 */;
McpClient.SyncSpec spec = McpClient.sync(namedTransport.transport())
.clientInfo(clientInfo)
.requestTimeout(commonProperties.getRequestTimeout());
clientMcpSyncHandlersRegistry.ifAvailable(registry -> spec
.sampling(samplingRequest -> registry.handleSampling(namedTransport.name(), samplingRequest))
.elicitation(el -> registry.handleElicitation(namedTransport.name(), el))
.loggingConsumer(n -> registry.handleLogging(namedTransport.name(), n))
.progressConsumer(p -> registry.handleProgress(namedTransport.name(), p))
.toolsChangeConsumer(newTools -> registry.handleToolListChanged(namedTransport.name(), newTools))
.promptsChangeConsumer(newPrompts -> registry.handlePromptListChanged(namedTransport.name(), newPrompts))
.resourcesChangeConsumer(newResources -> registry.handleResourceListChanged(namedTransport.name(), newResources))
.capabilities(registry.getCapabilities(namedTransport.name())));
McpClient.SyncSpec customizedSpec = mcpSyncClientConfigurer.configure(namedTransport.name(), spec);
var client = customizedSpec.build();
if (commonProperties.isInitialized()) {
client.initialize();
}
mcpSyncClients.add(client);
}
}
return mcpSyncClients;
}
四个必须知道的时序事实:
(a) 注解处理器先注册,McpClientCustomizer 后执行。 clientMcpSyncHandlersRegistry.ifAvailable(...) 在这里直接把七个回调挂上;而自定义器在 mcpSyncClientConfigurer.configure(...) 里跑。所以自定义器里再调 spec.toolsChangeConsumer(...) 会覆盖注解处理器设的那个。
这直接决定了 13.6 要讲的热更新链路能否工作。
(b) requestTimeout 默认 20 秒 (McpClientCommonProperties:63)。这是每个 MCP 请求 的超时,不是整个工具循环的超时。一个跑 5 轮的工具循环,若每轮都打一次 MCP,最坏是 5 × 20s = 100s------外层 HTTP 接口很可能已经超时了。
配方 :把 spring.ai.mcp.client.request-timeout 设到明显小于外层网关超时,例如网关 30s → MCP 设 10s。
(c) initialized=true(默认)意味着"启动时同步握手"。 client.initialize() 在 Bean 创建时同步执行。任何一个 MCP server 不可达,整个应用启动失败。 这在"本地开发没起 MCP server 就跑单测"的场景下非常痛。
配方 :开发环境设 spring.ai.mcp.client.initialized=false,改由业务代码按需 client.initialize();或者用 @ConditionalOnProperty 按 profile 隔离 MCP starter。
(d) ASYNC 分支用 .block() (:281):
java
if (commonProperties.isInitialized()) {
client.initialize().block();
}
虽然客户端是异步的,初始化仍然是阻塞的。所以 (c) 的结论对 ASYNC 同样成立。
13.3.4 生命周期:CloseableMcpSyncClients
java
// McpClientAutoConfiguration.java:313-319
public record CloseableMcpSyncClients(List<McpSyncClient> clients) implements AutoCloseable {
@Override
public void close() {
this.clients.forEach(McpSyncClient::close);
}
}
一个 record 实现 AutoCloseable,Spring 会在上下文关闭时调用它。注意它是通过 @Bean 方法的返回类型推断销毁的 ------Spring Boot 对 AutoCloseable Bean 默认走 close()(除非 destroyMethod 被显式设为 "")。
陷阱 :CloseableMcpSyncClients(List<McpSyncClient> clients) 注入的是 List<McpSyncClient>,而这个 Bean 自己也是 mcpSyncClients(...) 的依赖方吗?不是------它没有依赖,只是接收列表。但 Spring 的 @Bean 方法参数注入 List<T> 会触发所有 T 类型 Bean 的初始化 ,所以这个"包装 Bean"必须在 mcpSyncClients 之后创建(Spring 按依赖关系保证)。
更重要的一点 :STDIO 传输 fork 出的子进程是否随 client.close() 终止,取决于 SDK 的 StdioClientTransport 实现。Spring AI 这一层没有 额外的进程清理。如果你的应用反复重启而 MCP 子进程残留,这里是第一个要查的地方。
13.3.5 McpClientCustomizer 的两种泛型用法
java
// mcp/common/src/main/java/org/springframework/ai/mcp/customizer/McpClientCustomizer.java:36-48
public interface McpClientCustomizer<B> {
void customize(String name, B componentBuilder);
}
B 可以是两类东西,它们的注入点完全不同:
B |
谁调用 | 注入点 |
|---|---|---|
McpClient.SyncSpec / McpClient.AsyncSpec |
McpSyncClientConfigurer / McpAsyncClientConfigurer |
McpClientAutoConfiguration 建客户端时 |
HttpClientStreamableHttpTransport.Builder / WebClientStreamableHttpTransport.Builder / HttpClientSseClientTransport.Builder / WebFluxSseClientTransport.Builder |
各自的传输自动配置 | 建传输时(早于建客户端) |
java
// McpClientAutoConfiguration.java:222-229
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(..., name = "type", havingValue = "SYNC", matchIfMissing = true)
McpSyncClientConfigurer mcpSyncClientConfigurer(
ObjectProvider<McpClientCustomizer<McpClient.SyncSpec>> customizerProvider) {
return new McpSyncClientConfigurer(customizerProvider.orderedStream().toList());
}
注意 orderedStream() ------这里 @Order 是生效的(与 13.2.3 里"sync 组整体排在 async 组之前"是两回事)。
而传输自动配置那边用的是 for (McpClientCustomizer<...> customizer : transportCustomizers)(直接迭代 ObjectProvider,不排序):
java
// StreamableHttpWebFluxTransportAutoConfiguration.java:114-116
for (McpClientCustomizer<WebClientStreamableHttpTransport.Builder> customizer : transportCustomizers) {
customizer.customize(connectionName, transportBuilder);
}
所以:client spec 自定义器有序,transport builder 自定义器无序。 想控制传输层的顺序,只能自己合并成一个 Bean。
13.4 工具聚合:SyncMcpToolCallbackProvider / AsyncMcpToolCallbackProvider
13.4.1 类声明与缓存
java
// SyncMcpToolCallbackProvider.java:43
public class SyncMcpToolCallbackProvider implements ToolCallbackProvider, ApplicationListener<McpToolsChangedEvent> {
两个职责:ToolCallbackProvider(给 Spring AI 提供工具) + ApplicationListener<McpToolsChangedEvent>(热更新)。
四个状态字段(:45-57):
java
private final List<McpSyncClient> mcpClients;
private final McpToolFilter toolFilter;
private final McpToolNamePrefixGenerator toolNamePrefixGenerator;
private final ToolContextToMcpMetaConverter toolContextToMcpMetaConverter;
private volatile boolean invalidateCache = true;
private volatile List<ToolCallback> cachedToolCallbacks = List.of();
private final Lock lock = new ReentrantLock();
13.4.2 getToolCallbacks():双检锁 + 每 client 一次网络往返
java
// 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]);
}
五个要点:
- 双检锁(DCL) :外层无锁读
volatile invalidateCache,内层加锁后再查一次。标准写法。 mcpClient.listTools()是同步网络调用。 首次调用会阻塞到所有 MCP server 响应。N 个 server 串行(stream 是顺序的)→ 最坏 N × 20s。connectionInfo(mcpClient)被调用了两次 (一次给 filter、一次给 prefix generator),每次都新建一个McpConnectionInfo(:169-175)。而McpConnectionInfo.build()会Assert.state两个非空(:98-99)。validateToolCallbacks在缓存写入前执行 ,失败则抛异常、invalidateCache保持true→ 下一次调用会重试(而不是缓存一个失败状态)。这是对的。cachedToolCallbacks.toArray(new ToolCallback[0])每次都新建数组 ------每次getToolCallbacks()都分配一个新数组。对高频调用场景是小额 GC 压力。
13.4.3 Async 版的差异:.block()
java
// AsyncMcpToolCallbackProvider.java:141-158
for (McpAsyncClient mcpClient : this.mcpClients) {
ToolCallback[] toolCallbacks = mcpClient.listTools()
.map(response -> response.tools()
.stream()
.filter(tool -> this.toolFilter.test(connectionInfo(mcpClient), tool))
.<ToolCallback>map(tool -> AsyncMcpToolCallback.builder()
.mcpClient(mcpClient)
.tool(tool)
.prefixedToolName(this.toolNamePrefixGenerator
.prefixedToolName(connectionInfo(mcpClient), tool))
.toolContextToMcpMetaConverter(this.toolContextToMcpMetaConverter)
.build())
.toArray(ToolCallback[]::new))
.block();
toolCallbackList.addAll(List.of(toolCallbacks));
}
AsyncMcpToolCallbackProvider 的名字里有 Async,但 getToolCallbacks() 是阻塞的 (.block())。所谓"async"只体现在工具执行 阶段(AsyncMcpToolCallback.call() 内部 .block() 之前是 Mono)。
这在 reactive 栈里是一个真实的阻塞点 :如果你在 WebFlux 的 event loop 线程上首次触发 getToolCallbacks(),.block() 会抛 IllegalStateException("block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-x")。
配方 :在启动时预热(例如在 ApplicationRunner 里调一次 provider.getToolCallbacks()),或者把首次调用包在 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) 里。
另外注意 Assert 的来源不同 :AsyncMcpToolCallbackProvider 用的是 io.modelcontextprotocol.util.Assert(:25),而 SyncMcpToolCallbackProvider 用的是 org.springframework.util.Assert(:30)。两者行为略有差异(前者对 null 消息的处理不同),但在这里的用法等价。
13.4.4 validateToolCallbacks:重名即崩
java
// SyncMcpToolCallbackProvider.java:182-188
private void validateToolCallbacks(List<ToolCallback> toolCallbacks) {
List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
if (!duplicateToolNames.isEmpty()) {
throw new IllegalStateException(
"Multiple tools with the same name (%s)".formatted(String.join(", ", duplicateToolNames)));
}
}
ToolUtils.getDuplicateToolNames(spring-ai-model/.../tool/support/ToolUtils.java:106-116)按 getToolDefinition().name() 分组计数,返回出现次数 > 1 的名字。
同样的校验还有第二处:
java
// spring-ai-model/.../model/tool/ToolCallingChatOptions.java:92-99
static void validateToolCallbacks(@Nullable List<ToolCallback> toolCallbacks) {
if (CollectionUtils.isEmpty(toolCallbacks)) {
return;
}
List<String> duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks);
if (!duplicateToolNames.isEmpty()) {
throw new IllegalStateException("Multiple tools with the same name (%s) found in ToolCallingChatOptions"
.formatted(String.join(", ", duplicateToolNames)));
}
}
所以重名工具会在两个地方炸 ,消息略有不同(后者多了 found in ToolCallingChatOptions)。这给了你定位的线索:看到带 found in ToolCallingChatOptions 的,说明重名发生在"请求级 options 与默认 options 合并之后";不带后缀的,说明是 MCP provider 自己发现列表里就重了。
13.4.5 Builder 的默认值差异
| 字段 | SyncMcpToolCallbackProvider.Builder |
AsyncMcpToolCallbackProvider.Builder |
|---|---|---|
toolFilter |
(mcpClient, tool) -> true |
(mcpClient, tool) -> true |
toolNamePrefixGenerator |
new DefaultMcpToolNamePrefixGenerator() |
new DefaultMcpToolNamePrefixGenerator() |
toolContextToMcpMetaConverter |
defaultConverter() |
defaultConverter() |
mcpClients |
new ArrayList<>() |
List.of() |
注意两者都不 Assert "至少一个 client" ------SyncMcpToolCallbackProvider.Builder.build() 里那段 Assert.notEmpty 是被注释掉的(:300-301):
java
public SyncMcpToolCallbackProvider build() {
// Assert.notEmpty(this.mcpClients, "At least one MCP client must be
// provided");
return new SyncMcpToolCallbackProvider(...);
}
所以零 client 的 provider 会静默返回空工具数组 (getToolCallbacks() 里 stream 为空 → 空 list → toArray 空数组)。没有任何日志。 如果你的 MCP 工具"莫名其妙一个都没有",先确认 mcpClients 列表非空。
13.5 命名冲突:本章的核心
13.5.1 问题从哪来
假设你接了两个 MCP server,都提供 getWeather:
css
server-a: getWeather(location) → 用 OpenWeatherMap
server-b: getWeather(city) → 用中国气象局
模型只能看到一个扁平的工具列表。两个同名 getWeather 会导致:
validateToolCallbacks抛 ISE(13.4.4),应用启动或用不起来;- 即使绕过校验,模型也分不清该调哪个。
所以需要"前缀/消歧"。Spring AI 给了三层机制,而它们互相不一致。
13.5.2 机制一:McpToolNamePrefixGenerator.noPrefix()
java
// McpToolNamePrefixGenerator.java:39-51
public interface McpToolNamePrefixGenerator {
String prefixedToolName(McpConnectionInfo mcpConnectionInfo, Tool tool);
/**
* Static factory method to create a no-op prefix generator that returns the tool name
* @return a prefix generator that returns the tool name as-is
*/
static McpToolNamePrefixGenerator noPrefix() {
return (mcpConnectionInfo, tool) -> tool.name();
}
}
接口本身只有一个方法 + 一个静态工厂。noPrefix() 直接返回 tool.name(),不做任何 format() 净化------所以特殊字符会原样保留。
谁在用 noPrefix()? 两处:
- 所有已废弃的构造函数 (
SyncMcpToolCallbackProvider:67、AsyncMcpToolCallbackProvider:69)。 McpToolCallbackAutoConfiguration的 fallback (:80-82、:96-97):getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix())。
第 2 处是最危险的 :只要容器里 McpToolNamePrefixGenerator Bean 不是"恰好一个",就退回 noPrefix()。而 McpToolCallbackAutoConfiguration 自己就 @ConditionalOnMissingBean 提供了一个(:50-54),所以正常情况下恰好一个 → 用 DefaultMcpToolNamePrefixGenerator。
但如果你在代码里又定义了一个,就会有两个 → getIfUnique 返回 null → noPrefix() → 重名崩溃。 而且没有任何日志告诉你是这么回事。
13.5.3 机制二:DefaultMcpToolNamePrefixGenerator------以及它的伪幂等
这是 builder 的默认实现(SyncMcpToolCallbackProvider:223、AsyncMcpToolCallbackProvider:241)。完整源码:
java
// DefaultMcpToolNamePrefixGenerator.java:49-85
public class DefaultMcpToolNamePrefixGenerator implements McpToolNamePrefixGenerator {
private static final Log logger = LogFactory.getLog(DefaultMcpToolNamePrefixGenerator.class);
// Idempotency tracking. For a given combination of (client, server, tool) we will
// generate a unique tool name only once.
private final Set<ConnectionId> existingConnections = ConcurrentHashMap.newKeySet();
private final Set<String> allUsedToolNames = ConcurrentHashMap.newKeySet();
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public String prefixedToolName(McpConnectionInfo mcpConnectionInfo, McpSchema.Tool tool) {
String uniqueToolName = McpToolUtils.format(tool.name());
if (this.existingConnections
.add(new ConnectionId(mcpConnectionInfo.clientInfo(), (mcpConnectionInfo.initializeResult() != null)
? mcpConnectionInfo.initializeResult().serverInfo() : null, tool))) {
if (!this.allUsedToolNames.add(uniqueToolName)) {
uniqueToolName = "alt_" + this.counter.getAndIncrement() + "_" + uniqueToolName;
this.allUsedToolNames.add(uniqueToolName);
if (logger.isWarnEnabled()) {
logger.warn("Tool name '" + tool.name() + "' already exists. Using unique tool name '"
+ uniqueToolName + "'");
}
}
}
return uniqueToolName;
}
private record ConnectionId(@Nullable Implementation clientInfo, @Nullable Implementation serverInfo, Tool tool) {
}
}
先看它做对了什么:
- 用
ConcurrentHashMap.newKeySet()+AtomicInteger,Javadoc 声明 thread-safe (:45)。✔ - 冲突时打 WARN,明确说"Tool name 'X' already exists. Using unique tool name 'alt_1_X'"。✔
- 只对冲突 的工具加前缀,不冲突的工具保持原名 (
getWeather还是getWeather)。这是 2.x 相对 1.x 的重要改进------官方的措辞是"冲突时才加",而不是"总是加"。✔
再看它的致命问题:所谓的"幂等"没有持久化。
注释写的是:
Idempotency tracking. For a given combination of (client, server, tool) we will generate a unique tool name only once.
代码的执行逻辑是:
csharp
第 1 次调用(某连接某工具):
existingConnections.add(...) 返回 true → 进入 if
检查 allUsedToolNames,必要时分配 alt_N_ 前缀
返回 alt_N_xxx(或 xxx)
第 2..N 次调用(同一连接同一工具):
existingConnections.add(...) 返回 false → 跳过整个 if
返回 McpToolUtils.format(tool.name()) ← 恒等于原始名,不可能是 alt_N_xxx
它只在"第一次"分配名字,但从来没把第一次分配的结果记下来。 于是第二次开始一律返回原始名。
推演一次完整的失败过程:
csharp
初始状态:existingConnections = {},allUsedToolNames = {},counter = 1
── 首次 getToolCallbacks() ──
server-a / getWeather:
ConnectionId(client-a, server-a, toolA).add → true
allUsedToolNames.add("getWeather") → true(首次)
返回 "getWeather" → OK
server-b / getWeather:
ConnectionId(client-b, server-b, toolB).add → true
allUsedToolNames.add("getWeather") → false(已存在)
→ uniqueToolName = "alt_1_getWeather",加入 set,WARN
返回 "alt_1_getWeather" → OK
validateToolCallbacks: ["getWeather", "alt_1_getWeather"] 无重复 → 通过
── MCP server 推送 tools/list_changed,触发 invalidateCache() ──
── 第二次 getToolCallbacks() ──
server-a / getWeather:
ConnectionId(...).add → false(已存在)
跳过 if,返回 format("getWeather") = "getWeather" → 碰巧正确
server-b / getWeather:
ConnectionId(...).add → false(已存在)
跳过 if,返回 format("getWeather") = "getWeather" → ✗ 名字退化!
validateToolCallbacks: ["getWeather", "getWeather"] 有重复
→ throw IllegalStateException("Multiple tools with the same name (getWeather)")
这是一个确凿的实现缺陷,而且它的触发条件非常常见:
- 任何一次
McpToolsChangedEvent(工具热更新,13.6); - 任何一次手动
provider.invalidateCache(); - 同一个 generator 实例被两个 provider 共用(Boot 自动配置下正是如此------
McpToolCallbackAutoConfiguration的@ConditionalOnMissingBeanBean 是单例,而两个 provider 的@Bean方法都注入它;不过 SYNC/ASYNC 互斥,所以通常只有一个 provider)。
这个缺陷要成立,还需要一个前提 :existingConnections.add(...) 在第二次返回 false,即 ConnectionId 的 equals 判定相等。ConnectionId 是 record,其 equals 依赖三个组件的 equals:
clientInfo:McpSchema.Implementation------MCP SDK 类型,通常是 record,值相等。✔serverInfo:同上,但注意initializeResult为 null 时传 null。tool:McpSchema.Tool------关键变量 。如果它是 record 且两次listTools()返回的实例内容相同,则相等 → add 返回 false → bug 触发。如果 SDK 用的是 identity equals 或者每次返回的Tool带时间戳之类变化字段,则 add 返回 true → 反而每次都重新分配alt_N_,计数器持续递增 (alt_1、alt_2...),工具名在每次刷新后都变。
两种分支都是坏的:
- 相等 → 名字退化 → 崩溃。
- 不等 →
counter单调递增 → 工具名每次刷新都不同 (alt_1_getWeather→alt_2_getWeather→ ...),模型侧完全无法稳定引用,而且allUsedToolNames无界增长(内存泄漏)。
无法在本地直接验证 MCP SDK 的 Tool.equals 语义 (本仓库不含 SDK 源码,~/.m2 中也没有对应 jar),所以这里给出判定方法而不是断言:
shell
# 判定方法:在应用里打印
System.out.println(tool.getClass().isRecord()); // true 则按值比较
规避方案(三选一):
方案 A:自定义 generator,自己持久化映射。 最可靠。
java
public final class StableMcpToolNamePrefixGenerator implements McpToolNamePrefixGenerator {
private final ConcurrentMap<String, String> assigned = new ConcurrentHashMap<>();
private final AtomicInteger counter = new AtomicInteger(1);
private final Set<String> used = ConcurrentHashMap.newKeySet();
@Override
public String prefixedToolName(McpConnectionInfo info, McpSchema.Tool tool) {
String key = connectionKey(info, tool);
// computeIfAbsent 保证同一连接同一工具永远拿到同一个名字
return this.assigned.computeIfAbsent(key, k -> {
String base = McpToolUtils.format(tool.name());
String candidate = base;
while (!this.used.add(candidate)) {
candidate = "alt_" + this.counter.getAndIncrement() + "_" + base;
}
return candidate;
});
}
private static String connectionKey(McpConnectionInfo info, McpSchema.Tool tool) {
String server = (info.initializeResult() != null && info.initializeResult().serverInfo() != null)
? info.initializeResult().serverInfo().name() : "unknown";
return info.clientInfo().name() + "|" + server + "|" + tool.name();
}
}
注意这里用 tool.name() 而不是 Tool 对象 做 key------避开 SDK equals 语义的不确定性。
方案 B:禁用热更新。 不注册 McpSyncToolsChangeEventEmmiter(或设 spring.ai.mcp.client.type=SYNC 但自定义 McpSyncClientConfigurer 覆盖 toolsChangeConsumer),让 invalidateCache 永不被触发。代价是工具变更不生效。
方案 C:用 McpToolFilter 主动去重。 在 filter 层面直接拒掉重名工具(13.7),从源头避免进入消歧逻辑。代价是其中一个 server 的工具不可用。
13.5.4 机制三:McpToolUtils.prefixedToolName------64 字符截断的出处
这是第 11 章那条 WARN 里 "truncated due to length limits" 的唯一来源:
java
// McpToolUtils.java:92-111
public static String prefixedToolName(String prefix, @Nullable String title, String toolName) {
if (StringUtils.isEmpty(prefix) || StringUtils.isEmpty(toolName)) {
throw new IllegalArgumentException("Prefix or toolName cannot be null or empty");
}
String input = shorten(format(prefix));
if (!StringUtils.isEmpty(title)) {
input = input + "_" + format(title); // Do not shorten the title.
}
input = input + "_" + format(toolName);
// If the string is longer than 64 characters, keep the last 64 characters
if (input.length() > 64) {
input = input.substring(input.length() - 64);
}
return input;
}
逐步拆解:
(a) format(prefix) → shorten(...)。 prefix 被缩写成首字母序列。
(b) title 不缩写(注释明写 "Do not shorten the title"),因为它承载 server 身份。
(c) format(toolName) 原样保留(不缩写)------工具名是模型要读的语义。
(d) 超过 64 字符时保留最后 64 个 (substring(length - 64))。保留尾部 意味着:当长度超限时,被砍掉的是开头的前缀/title ,而工具名被完整保留。这是一个合理的选择------宁可丢 server 身份,也不能改工具名。
但危险在于 :砍掉前缀后,两个不同 server 的同名工具可能变成完全相同的字符串:
vbscript
server-alpha + getWeather → "..._server_alpha_getWeather" 截断后 = "...er_alpha_getWeather"
server-beta + getWeather → "..._server_beta_getWeather" 截断后 = "...er_beta_getWeather"
只要尾部 64 字符里包含 title,就仍能区分。真正会撞的是 prefix 很长而 title 被整体截断掉的情况 ------此时两个 server 的同名工具会得到同一个名字,然后被 validateToolCallbacks 打成 ISE,或者更糟:撞上一个不同名但尾部相同的工具。
这就是第 11 章那条 WARN 的完整因果链:
javascript
MCP 工具名被 prefixedToolName 拼成 >64 字符
→ 尾部截断,前缀信息丢失
→ JSON Schema 发给模型时,某些供应商(OpenAI 有 64 字符工具名上限)会再截一次/改写
→ 模型回传的 tool name 与本地注册的对不上
→ DefaultToolCallingManager 找不到 ToolCallback
→ WARN "LLM may have adapted the tool name 'xxx', especially if the name was truncated
due to length limits. If this is the case, you can customize the prefixing
and processing logic using McpToolNamePrefixGenerator"
→ throw IllegalStateException("No ToolCallback found for tool name: " + toolName)
注意告警的建议本身有点误导 :它让你"用 McpToolNamePrefixGenerator 自定义",但 DefaultMcpToolNamePrefixGenerator 根本不走 prefixedToolName (它只用 format())。所以:
- 如果你用的是 默认
DefaultMcpToolNamePrefixGenerator,不会 发生 64 截断(它只输出format(tool.name())或alt_N_+format(tool.name()))。此时看到那条 WARN,原因多半是工具名本身就长 (format后仍超 64,被供应商截断),而不是 Spring AI 拼的。 - 如果你用的是废弃构造函数或自定义调用
prefixedToolName,才会触发 Spring AI 侧的截断。
修复建议 :把 MCP server 的工具名控制在 40 字符以内,给前缀留 24 字符余量。
13.5.5 format():字符过滤的隐藏冲突源
java
// McpToolUtils.java:117-125
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("-", "_");
}
两步:先删非法字符,再把 - 换成 _。
注意正则的两个细节:
-
支持中文 。白名单里显式列了
\p{IsHan}、\p{InCJK_Unified_Ideographs}、\p{InCJK_Compatibility_Ideographs}三个 Unicode 块。注释写明是 "for complete Chinese character coverage"。这是 2.x 对中文工具名的显式支持。但要小心 :
\p{InCJK_Compatibility_Ideographs之外还有很多中文标点(,。、:)不在 白名单 → 被删除。而全角数字/字母 (ABC123)也不在白名单(\p{InCJK_Unified_Ideographs}不含全角拉丁)→ 被删除。 -
被"删除"而不是"替换",这是冲突的来源:
lua
"get.weather" → format → "getweather"
"get weather" → format → "getweather"
"get_weather" → format → "get_weather" (下划线保留)
"get-weather" → format → "get_weather" (连字符换成下划线)
get.weather 和 get weather 和 getweather 三个不同的 MCP 工具名,format 后全部变成 getweather。 如果它们来自同一个 server 的不同工具,validateToolCallbacks 直接崩。
而且这个冲突发生在 DefaultMcpToolNamePrefixGenerator 里 ------它第 64 行 McpToolUtils.format(tool.name()) 就做了这件事,随后 allUsedToolNames.add(uniqueToolName) 返回 false → 分配 alt_1_getweather。所以默认实现能救这个场景(第一次),但 13.5.3 的伪幂等问题依旧。
13.5.6 shorten():首字母缩写
java
// McpToolUtils.java:132-141
private static String shorten(String input) {
if (input == null || input.isEmpty()) {
return "";
}
return Stream.of(input.toLowerCase(Locale.ROOT).split("_"))
.filter(word -> !word.isEmpty())
.map(word -> String.valueOf(word.charAt(0)))
.collect(java.util.stream.Collectors.joining("_"));
}
把 spring_ai_mcp_client_server1 变成 s_a_m_c_s。
三个细节:
toLowerCase(Locale.ROOT)------ 用了显式 Locale,避免土耳其语I问题。✔word.charAt(0)------ 中文字符取第一个字。天气查询→天。不缩写(因为format已保证它合法)。filter(word -> !word.isEmpty())------ 处理连续下划线。但注意:split("_")对"a__b"得到["a", "", "b"],过滤后["a","b"]→"a_b"。前导下划线会产生一个空串被过滤 ,所以"_abc"→"abc"。
13.5.7 三套命名策略的完整对比与推荐
| 策略 | 触发条件 | 输出形态 | 是否截断 | 是否幂等 |
|---|---|---|---|---|
noPrefix() |
废弃构造函数 / getIfUnique 拿不到唯一 Bean |
tool.name()(未 format) |
否 | 是(纯函数) |
DefaultMcpToolNamePrefixGenerator |
builder 默认 / Boot 自动配置的 @ConditionalOnMissingBean Bean |
format(name) 或 alt_N_format(name) |
否 | 否(伪幂等,见 13.5.3) |
McpToolUtils.prefixedToolName |
废弃构造函数 / 你自己调用 | shorten(format(prefix))[_format(title)]_format(name),>64 取尾部 |
是 | 是(纯函数) |
推荐:
- 生产环境务必自定义
McpToolNamePrefixGenerator(方案 A),并且确保容器里只有一个 该类型的 Bean(否则getIfUnique静默退回noPrefix())。 - 不要用废弃的 public 构造函数 ------它们绑定了
noPrefix()+prefixedToolName的旧行为。 - MCP server 侧的工具名控制在 40 字符内、只用
[a-zA-Z0-9_-],中文可用但要避开中文标点。 - 开启 WARN 日志并监控 :
DefaultMcpToolNamePrefixGenerator打出的 "Tool name 'X' already exists. Using unique tool name 'alt_N_X'" 是唯一告诉你发生了消歧的信号。它出现就意味着你的工具命名有设计问题。
13.6 McpToolsChangedEvent:热更新的完整链路与它的三个断点
13.6.1 事件本体
java
// mcp/common/.../McpToolsChangedEvent.java:30-50
public class McpToolsChangedEvent extends ApplicationEvent {
private final String connectionName;
private final List<Tool> tools;
public McpToolsChangedEvent(String connectionName, List<Tool> tools) {
super(connectionName);
this.connectionName = connectionName;
this.tools = tools;
}
public String getConnectionName() {
return this.connectionName;
}
public List<Tool> getTools() {
return this.tools;
}
}
注意 super(connectionName) ------ ApplicationEvent 的 source 被设成连接名字符串。这意味着 event.getSource() 返回的是 String,不是某个 MCP 对象。
事件里没有 client 引用。 只有连接名和新工具列表。所以监听器无法 知道"是哪个 McpSyncClient 变更了",也无法只刷新那一个------只能全量失效。
13.6.2 发射器:通过 McpClientCustomizer 挂载
java
// McpSyncToolsChangeEventEmmiter.java:32-48
public class McpSyncToolsChangeEventEmmiter implements McpClientCustomizer<SyncSpec> {
private final ApplicationEventPublisher applicationEventPublisher;
@Override
public void customize(String connectionName, SyncSpec spec) {
spec.toolsChangeConsumer(
tools -> this.applicationEventPublisher.publishEvent(new McpToolsChangedEvent(connectionName, tools)));
}
}
Async 版多一层 Mono:
java
// McpAsyncToolsChangeEventEmmiter.java(:41-47)
@Override
public void customize(String connectionName, AsyncSpec spec) {
spec.toolsChangeConsumer(tools -> {
this.applicationEventPublisher.publishEvent(new McpToolsChangedEvent(connectionName, tools));
return Mono.empty();
});
}
关键:toolsChangeConsumer 是 MCP 协议的 notifications/tools/list_changed 回调。 也就是说:
- 只有 server 主动推送该通知时才会触发。 轮询不会发生。
- server 必须在初始化时声明支持
tools.listChangedcapability,否则客户端不会收到。
13.6.3 断点一:注解处理器会覆盖发射器
回顾 13.3.3 的 (a):
java
clientMcpSyncHandlersRegistry.ifAvailable(registry -> spec
...
.toolsChangeConsumer(newTools -> registry.handleToolListChanged(namedTransport.name(), newTools))
...
.capabilities(registry.getCapabilities(namedTransport.name())));
McpClient.SyncSpec customizedSpec = mcpSyncClientConfigurer.configure(namedTransport.name(), spec);
clientMcpSyncHandlersRegistry.ifAvailable(...) 先执行,设置了 toolsChangeConsumer;然后 mcpSyncClientConfigurer.configure(...) 里跑所有 McpClientCustomizer,其中就包括 McpSyncToolsChangeEventEmmiter。
由于 McpSyncToolsChangeEventEmmiter 是后 执行的,它调用 spec.toolsChangeConsumer(...) 会覆盖注解处理器设的那个。
所以:如果你的应用里注册了 ClientMcpSyncHandlersRegistry(即用了 @McpToolListChanged 注解),并且 McpSyncToolsChangeEventEmmiter Bean 也在(默认就有),那么:
McpToolsChangedEvent会被发出(emitter 赢了);- 你的
@McpToolListChanged方法不会被调用(被覆盖了)。
这是一个"后者静默胜出"的冲突。 想两者都要,只能自己写一个 customizer 同时调两个。
反过来,如果你 没有 注解处理器(ifAvailable 不触发),emitter 是唯一的 toolsChangeConsumer,链路干净。
13.6.4 断点二:事件的订阅者是谁
java
// SyncMcpToolCallbackProvider.java:164-167
@Override
public void onApplicationEvent(McpToolsChangedEvent event) {
this.invalidateCache();
}
只有一行:失效缓存。它完全忽略 event.getTools() ------ 明明事件里带了新工具列表,provider 却不去用它,而是选择"下次再 listTools() 一次"。
这是一个保守但浪费 的选择:收到通知后再发一次 tools/list 请求。好处是实现简单、无需处理部分更新;代价是每个变更通知都会带来一次额外的网络往返,而且如果 server 频繁推(例如每次配置变更),客户端会被打爆。
没有任何限流/去抖。 如果你的 MCP server 每秒推一次 list_changed,客户端就每秒 listTools() 一次。
配方 :自定义一个 McpClientCustomizer<SyncSpec> 排在 emitter 之后,包一层去抖:
java
@Component
@Order(Ordered.LOWEST_PRECEDENCE) // 排在 emitter 之后
public class DebouncedToolsChangeCustomizer implements McpClientCustomizer<McpClient.SyncSpec> {
private final ApplicationEventPublisher publisher;
private final ConcurrentMap<String, AtomicLong> lastEmit = new ConcurrentHashMap<>();
@Override
public void customize(String name, McpClient.SyncSpec spec) {
spec.toolsChangeConsumer(tools -> {
long now = System.currentTimeMillis();
long last = this.lastEmit.computeIfAbsent(name, k -> new AtomicLong(0)).get();
if (now - last < 5_000) {
return; // 5 秒内只处理一次
}
this.lastEmit.get(name).set(now);
this.publisher.publishEvent(new McpToolsChangedEvent(name, tools));
});
}
}
13.6.5 断点三:失效之后是崩溃
这是 13.5.3 的直接后果,也是最值得记住的一条:
scss
server 推送 list_changed
→ emitter 发 McpToolsChangedEvent
→ provider.onApplicationEvent → invalidateCache()
→ 下次 getToolCallbacks() 重新聚合
→ DefaultMcpToolNamePrefixGenerator 走 "已存在" 短路分支
→ 重名工具全部退回 format(tool.name())
→ validateToolCallbacks 抛 IllegalStateException
→ 该次请求失败
更糟的是这个失败发生在请求线程上,而不是启动时。 应用跑得好好的,某个 MCP server 推了一次工具变更,随后所有 ChatClient 调用全崩。
而且 invalidateCache 保持 true(因为异常在赋值前抛出) ,所以之后每次调用都会重试、都会崩------不是"偶发一次",是"从此一直崩"。
唯一的出路是 13.5.3 给的方案 A(自定义 generator)或方案 C(filter 去重)。
13.6.6 完整链路图
scss
MCP server
│ notifications/tools/list_changed
▼
McpSyncClient(SDK)
│ spec.toolsChangeConsumer(...) ← 被设置两次:①注解处理器 ②emitter(②胜出)
▼
McpSyncToolsChangeEventEmmiter.customize 注册的 consumer
│ publishEvent(new McpToolsChangedEvent(connectionName, tools))
▼
ApplicationContext
│
▼
SyncMcpToolCallbackProvider.onApplicationEvent
│ invalidateCache() = true ← 忽略 event.getTools()
▼
下一次 getToolCallbacks()
│ mcpClient.listTools() ← 额外的网络往返
│ toolFilter.test(...) ← 13.7
│ toolNamePrefixGenerator.prefixedToolName(...) ← 13.5(伪幂等bug在此)
│ validateToolCallbacks(...) ← 可能抛 ISE
▼
ToolCallback[]
13.7 McpToolFilter:一个 BiPredicate,三种用法
java
// McpToolFilter.java:30-32
public interface McpToolFilter extends BiPredicate<McpConnectionInfo, McpSchema.Tool> {
}
空接口,纯粹的 BiPredicate 别名。它拿到的 McpConnectionInfo 包含 clientInfo、clientCapabilities、initializeResult(McpConnectionInfo:33-36),所以你可以按 server 身份、按能力、按工具本身来过滤。
13.7.1 注入点:getIfUnique 又一次
java
// McpToolCallbackAutoConfiguration.java:78-85
return SyncMcpToolCallbackProvider.builder()
.mcpClients(mcpClients)
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (McpSyncClient, tool) -> true)))
.toolNamePrefixGenerator(
mcpToolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()))
.toolContextToMcpMetaConverter(
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
.build();
同样的陷阱 :定义两个 McpToolFilter Bean → getIfUnique 返回 null → fallback (mcpClient, tool) -> true(全部放行)。
注意这里没有 @ConditionalOnMissingBean 的默认 Bean (和 prefix generator 不同),所以你不定义就用"全放行",定义了两个也用"全放行"。区别只是后者是你自己造成的。
13.7.2 用法一:按 server 白名单
java
@Bean
McpToolFilter trustedServersOnly() {
return (connectionInfo, tool) -> {
var initResult = connectionInfo.initializeResult();
if (initResult == null || initResult.serverInfo() == null) {
return false; // 未握手的连接,一律不放行
}
return TRUSTED_SERVERS.contains(initResult.serverInfo().name());
};
}
注意 initializeResult 是 @Nullable (McpConnectionInfo:36)。如果客户端还没握手(initialized=false),它是 null。必须判空。
13.7.3 用法二:按工具名去重(13.5.3 方案 C)
java
@Component
public class DedupMcpToolFilter implements McpToolFilter {
private final Set<String> seen = ConcurrentHashMap.newKeySet();
@Override
public boolean test(McpConnectionInfo connectionInfo, McpSchema.Tool tool) {
// 只放行首次出现的工具名,后续同名工具直接丢弃
return this.seen.add(McpToolUtils.format(tool.name()));
}
}
但这个实现有和 13.5.3 一样的问题 :seen 是实例级的,热更新后所有工具都被拒(因为都已在 seen 里)→ 工具列表变空。
正确版本 需要用"当前批次"的概念。由于 McpToolFilter 是 BiPredicate 且没有批次上下文,做不到。可行替代:在 filter 里按 server 优先级硬编码保留规则:
java
@Override
public boolean test(McpConnectionInfo info, McpSchema.Tool tool) {
String server = serverName(info);
// server-a 优先,它的 getWeather 保留;server-b 的同名工具丢弃
if ("getWeather".equals(tool.name()) && !"server-a".equals(server)) {
return false;
}
return true;
}
13.7.4 用法三:屏蔽高危工具
java
@Bean
McpToolFilter blockDangerousTools() {
Set<String> blocked = Set.of("executeShell", "deleteFile", "dropTable");
return (connectionInfo, tool) -> !blocked.contains(tool.name());
}
注意这是在发现阶段屏蔽,不是在执行阶段拦截。 区别:
| 阶段 | 后果 |
|---|---|
| 发现阶段(filter) | 模型根本看不到这个工具,schema 里没有它 |
执行阶段(自定义 ToolCallback / checker) |
模型看得到、可能调用,调用时被拒 |
安全上应该用发现阶段屏蔽 (模型不知道有这个能力,不会尝试绕过);审计/审批场景用执行阶段拦截(需要知道模型"想"干什么)。
13.7.5 filter 的一个副作用:减少 token
每个 MCP 工具的 JSON Schema 都要进 prompt。100 个工具 × 平均 300 token = 3 万 token。filter 是最直接的成本控制手段 ,比 ToolSearchToolCallingAdvisor(第 12 章 §12.5)简单得多。
配方:按业务场景构造多个 provider,每个配不同的 filter,运行时选择:
java
Map<String, ToolCallbackProvider> providersByScene = Map.of(
"order", SyncMcpToolCallbackProvider.builder().mcpClients(clients).toolFilter(orderTools()).build(),
"search", SyncMcpToolCallbackProvider.builder().mcpClients(clients).toolFilter(searchTools()).build()
);
13.8 ToolContextToMcpMetaConverter:把 Spring AI 的上下文带过协议
13.8.1 接口与两个工厂方法
java
// ToolContextToMcpMetaConverter.java:32-69
public interface ToolContextToMcpMetaConverter {
Map<String, Object> convert(ToolContext toolContext);
static ToolContextToMcpMetaConverter defaultConverter() {
return toolContext -> {
if (toolContext == null || CollectionUtils.isEmpty(toolContext.getContext())) {
return Map.of();
}
return toolContext.getContext()
.entrySet()
.stream()
.filter(entry -> !McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY.equals(entry.getKey())
&& entry.getValue() != null)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
};
}
static ToolContextToMcpMetaConverter noOp() {
return toolContext -> Map.of();
}
}
默认转换器做两件过滤:
-
剔除
exchangekey (McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY = "exchange",McpToolUtils:80)。这个 key 在服务端 用来存放McpSyncServerExchange------一个不可序列化的活对象。如果不剔除,序列化进 MCP meta 会直接炸。这是第 14 章要讲的服务端细节在客户端的对应防护:客户端要防止自己(或某个 advisor)往
ToolContext里塞的exchange被发出去。 -
剔除 value 为 null 的 entry 。
Collectors.toMap不允许 null value(会抛 NPE),所以必须先过滤。
13.8.2 完整传参链路
scss
你的代码 / advisor
│ .toolContext(Map.of("tenantId", "t-1", "userId", "u-9"))
▼
ToolCallingChatOptions.toolContext
│
▼ DefaultToolCallingManager.buildToolContext(prompt) (第 11 章 §11.3.2)
│ new HashMap<>(options.getToolContext()) ← 拷贝成可变 map
▼
ToolCallback.call(args, toolContext)
│
▼ SyncMcpToolCallback.call(...)
│ var mcpMeta = toolContext != null ? converter.convert(toolContext) : null;
▼
CallToolRequest.builder(tool.name()).arguments(arguments).meta(mcpMeta).build()
│
▼ MCP 协议 → server 收到 request.meta()
这条链路是"把租户 ID / 追踪 ID / 用户身份传给 MCP server"的唯一官方通道。 如果你想让 MCP server 知道"这次调用属于哪个租户",就用它。
13.8.3 三个陷阱
(a) ToolContext 为 null 时 meta 也是 null。
java
// SyncMcpToolCallback.java:127
var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null;
而第 10 章讲过:声明了 ToolContext 参数却传 null,MethodToolCallback 会抛 IllegalArgumentException。这里 MCP callback 温和得多------接受 null。
(b) meta 的值必须是可序列化的。 ToolContext 里可以放任何 Object,但 convert 不过滤类型。如果你塞了一个 DataSource,Jackson 序列化会失败(或产出巨量 JSON)。
配方:自定义 converter,白名单化:
java
@Bean
ToolContextToMcpMetaConverter safeConverter() {
return toolContext -> {
if (toolContext == null) {
return Map.of();
}
return toolContext.getContext().entrySet().stream()
.filter(e -> e.getValue() != null)
.filter(e -> !McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY.equals(e.getKey()))
.filter(e -> e.getValue() instanceof String || e.getValue() instanceof Number
|| e.getValue() instanceof Boolean)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
};
}
(c) 定义两个 converter Bean → getIfUnique 静默退回 defaultConverter() (McpToolCallbackAutoConfiguration:83-84)。你的白名单失效且无日志。
13.9 工具执行:SyncMcpToolCallback 与 AsyncMcpToolCallback
13.9.1 SyncMcpToolCallback.call() 完整解析
java
// SyncMcpToolCallback.java:111-158
@Override
public String call(String toolCallInput, @Nullable ToolContext toolContext) {
// Handle the possible null parameter situation in streaming mode.
if (!StringUtils.hasText(toolCallInput)) {
if (logger.isWarnEnabled()) {
logger.warn("Tool call arguments are null or empty for MCP tool: " + this.tool.name()
+ ". Using empty JSON object as default.");
}
toolCallInput = "{}";
}
Map<String, Object> arguments = jsonHelper.fromJsonToMap(toolCallInput);
CallToolResult response;
try {
var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null;
// Use the original tool name, not the prefixed one from getToolDefinition
var request = CallToolRequest.builder(this.tool.name()).arguments(arguments).meta(mcpMeta).build();
// Note that we use the original tool name here, not the adapted one from
// getToolDefinition
response = this.mcpClient.callTool(request);
}
catch (McpError ex) {
// A protocol-level error indicates the tool invocation itself failed and
// must not be conveyed to the model. Rethrow it as a hard failure, mirroring
// how @Tool treats checked exceptions and Errors. Since the tool calling
// manager only handles ToolExecutionException, this bubbles up and fails the
// model interaction.
logger.error("Protocol error while calling tool: ", ex);
throw ex;
}
catch (Exception ex) {
logger.error("Exception while tool calling: ", ex);
throw new ToolExecutionException(this.getToolDefinition(), ex);
}
if (response.isError() != null && response.isError()) {
if (logger.isErrorEnabled()) {
logger.error("Error calling tool: " + response.content());
}
throw new ToolExecutionException(this.getToolDefinition(),
new IllegalStateException("Error calling tool: " + response.content()));
}
return jsonHelper.toJson(response.content());
}
13.9.2 两个"名字"的分工
这是理解 MCP 工具调用的关键:
| 名字 | 来源 | 用在哪 |
|---|---|---|
prefixed getToolDefinition().name() |
toolNamePrefixGenerator.prefixedToolName(...) |
发给模型的 schema(模型引用它) |
original this.tool.name() |
MCP server 声明的原始名 | 发给 MCP server 的 CallToolRequest |
代码里两处注释都在强调这一点(:129、:132-133)。这是"本地消歧"与"远程原样"的分离 :Spring AI 侧为了去重加了 alt_1_ 前缀,但对 server 说话时必须用原名------server 不认识 alt_1_getWeather。
由此得出一个重要的排查结论 :如果你在 MCP server 的日志里看到 unknown tool: alt_1_getWeather,说明前缀泄漏到了请求里 ------这只可能是你自己实现了 McpToolNamePrefixGenerator 并且......不对,callback 里硬编码用 this.tool.name()。所以除非你自定义了 ToolCallback,否则不会发生。反过来,如果 server 日志看到的是原名但 server 说找不到,那是 server 侧的问题。
13.9.3 空的 tool call 参数 → {} 与那条 WARN
第 11 章 §11.3.3 讲过 DefaultToolCallingManager 里的兜底。这里还有第二处:
java
if (!StringUtils.hasText(toolCallInput)) {
logger.warn("Tool call arguments are null or empty for MCP tool: " + this.tool.name()
+ ". Using empty JSON object as default.");
toolCallInput = "{}";
}
为什么 MCP callback 要自己再来一遍? 因为 SyncMcpToolCallback 也可以被直接使用(不经过 manager)。而 jsonHelper.fromJsonToMap("") 会失败或返回 null,所以这里必须兜底。
后果 :无参工具(getServerTime())被模型调用时,模型可能不传 arguments → 两次兜底 → {} → server 收到空 arguments。如果 server 侧在 SDK 2.0 开了入参校验(upgrade-notes 说 2.0 默认开启),而工具 schema 声明了必填参数,就会返回 isError=true → 这里抛 ToolExecutionException。
13.9.4 McpError 硬抛 vs 其他异常包 ToolExecutionException
这是 2.0 的行为变更 ,与 upgrade-notes 里 @McpTool 的变更(upgrade-notes.adoc:130-149)是同一件事的客户端镜像:
java
catch (McpError ex) {
// A protocol-level error indicates the tool invocation itself failed and
// must not be conveyed to the model. Rethrow it as a hard failure, mirroring
// how @Tool treats checked exceptions and Errors. Since the tool calling
// manager only handles ToolExecutionException, this bubbles up and fails the
// model interaction.
logger.error("Protocol error while calling tool: ", ex);
throw ex; // ← 硬抛,不包装
}
catch (Exception ex) {
logger.error("Exception while tool calling: ", ex);
throw new ToolExecutionException(this.getToolDefinition(), ex); // ← 包装
}
语义 :McpError 是协议级 信号(例如 URL elicitation、协议违规),不是"工具执行失败"。把它变成"给模型的错误消息"是错的------模型会以为工具执行失败而重试,但实际问题在协议层,重试也没用。所以原样抛出,让整个模型交互失败。
而其他异常包成 ToolExecutionException,交给 DefaultToolExecutionExceptionProcessor(第 10 章)决定是回喂模型还是抛。
注意注释里的这句 :"Since the tool calling manager only handles ToolExecutionException, this bubbles up and fails the model interaction."
这意味着 McpError 会一路冒到你的 Controller ,不会被转成任何友好响应。这是第 11 章 §11.5.8 "出口 ④"的一个具体实例。
配方 :在 advisor 层捕获 McpError:
java
@Override
protected ChatClientResponse doAfterCall(ChatClientResponse response, CallAdvisorChain chain) {
// 无法在这里捕获------异常已经在 nextCall 里抛出
return response;
}
不行------executeToolCalls 的异常发生在 ToolCallingAdvisor.adviseCall 内部。可行做法是包一层 ToolCallback(第 12 章配方 1 的同款手法):
java
public final class McpErrorTolerantCallback implements ToolCallback {
private final ToolCallback delegate;
@Override
public String call(String input, @Nullable ToolContext ctx) {
try {
return this.delegate.call(input, ctx);
}
catch (McpError ex) {
// 转成给模型的消息,而不是让整个交互失败
return "The tool could not be invoked due to a protocol error (" + ex.getMessage()
+ "). Do not retry; report this to the user.";
}
}
}
13.9.5 isError=true 也抛异常
java
if (response.isError() != null && response.isError()) {
logger.error("Error calling tool: " + response.content());
throw new ToolExecutionException(this.getToolDefinition(),
new IllegalStateException("Error calling tool: " + response.content()));
}
MCP 协议里 CallToolResult.isError=true 表示"工具执行了但失败" (例如文件不存在、SQL 报错)。Spring AI 把它一律 转成 ToolExecutionException。
这是一个值得商榷的设计 :MCP 协议把 isError=true 设计成"给模型的正常反馈通道"------工具应该能用它告诉模型"你要的文件不存在,换个名字"。但 Spring AI 把它变成了异常,于是:
DefaultToolExecutionExceptionProcessor(第 10 章)在alwaysThrow=false(默认)时会把它转成字符串回喂模型 → 语义大体正确(模型确实看到了错误信息)。- 但消息格式是
IllegalStateException的getMessage()拼出来的,即"Error calling tool: [TextContent[...]]"------包含 MCP 内容对象的toString(),格式难看且冗长。
配方 :如果 MCP server 是你自己写的,优先用返回值而非 isError=true 传递可恢复的错误;只在真正不可恢复时用 isError=true。
13.9.6 Async 版的差异
java
// AsyncMcpToolCallback.java(:127-131)
response = this.mcpClient.callTool(request).onErrorMap(e -> !(e instanceof McpError), e -> {
logger.error("Exception while tool calling: ", e);
return new ToolExecutionException(this.getToolDefinition(), e);
})
.contextWrite(ctx -> ctx.putAll(ToolCallReactiveContextHolder.getContext()))
.block();
三点差异:
- 用
onErrorMap(predicate, mapper)而非 try-catch。predicate是!(e instanceof McpError)------即只对非McpError做转换,语义与 sync 版一致。 .contextWrite(ctx -> ctx.putAll(ToolCallReactiveContextHolder.getContext()))------ 这是第 11/12 章讲过的观测父子桥。它把 Reactor Context 里的 observation 传播到 MCP 调用链上,让工具 span 能挂在正确的父节点下。.block()------ 最终还是阻塞。所以AsyncMcpToolCallback的"async"体现在内部用 Reactor 编排 (含 contextWrite 传播),而对外仍是同步的String call(...)。
这带来一个真实的限制 :AsyncMcpToolCallback 无法在 WebFlux 的 event loop 线程上直接调用(.block() 会抛异常)。它依赖第 12 章讲的 ToolCallingAdvisor 里的 subscribeOn(Schedulers.boundedElastic())(ToolCallingAdvisor:403)把它挪到 boundedElastic。如果你绕过 advisor 直接用这个 callback,需要自己保证线程。
13.9.7 结果转换:jsonHelper.toJson(response.content())
返回值是 response.content() 的 JSON 序列化 ,即 MCP 的 Content 列表(List<TextContent | ImageContent | EmbeddedResource>)。
所以模型收到的不是"工具返回的文本",而是"内容块数组的 JSON",形如:
json
[{"type":"text","text":"北京今天 25℃,晴"}]
这是一个需要注意的格式:
- 模型看到的是一段 JSON,不是纯文本。大多数模型能理解,但会多耗 token。
- 图片内容 (
ImageContent)会被序列化成 base64 字符串塞进文本 → 巨量 token。如果你的 MCP server 返回图片,务必在 server 侧或 filter 层处理。
配方 :自定义 ToolCallback 包装,只提取 TextContent:
java
@Override
public String call(String input, @Nullable ToolContext ctx) {
String raw = this.delegate.call(input, ctx);
// raw 是 [{"type":"text","text":"..."}] 的 JSON
// 解析后只取 text 字段拼接
return extractTexts(raw);
}
13.10 MCP 工具与 Spring AI 工具体系的三个"不一样"
这一节把 MCP 工具相对于第 09/10/11/12 章讲的 @Tool 体系的差异集中说明。
13.10.1 不一样一:被排除出 ToolCallbackResolver
java
// auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java:96-108
totalToolCallbackProviders.stream()
.filter(pr -> !isMcpToolCallbackProvider(ResolvableType.forInstance(pr)))
.map(pr -> List.of(pr.getToolCallbacks()))
.forEach(allFunctionAndToolCallbacks::addAll);
var staticToolCallbackResolver = new StaticToolCallbackResolver(allFunctionAndToolCallbacks);
return new DelegatingToolCallbackResolver(List.of(staticToolCallbackResolver));
}
private static boolean isMcpToolCallbackProvider(ResolvableType type) {
if (type.getType().getTypeName().equals("org.springframework.ai.mcp.SyncMcpToolCallbackProvider")
|| type.getType().getTypeName().equals("org.springframework.ai.mcp.AsyncMcpToolCallbackProvider")) {
return true;
}
var superType = type.getSuperType();
return superType != ResolvableType.NONE && isMcpToolCallbackProvider(superType);
}
两个值得吐槽的实现细节:
- 用类名字符串比较 (
type.getType().getTypeName().equals(...)),而不是instanceof。这是为了避免spring-ai-mcp成为spring-ai-autoconfigure-model-tool的编译期依赖(可选依赖)。 - 递归检查父类 (
type.getSuperType())。所以如果你继承了SyncMcpToolCallbackProvider,你的子类也会被排除。这既可能是你想要的(继承即同行为),也可能是意外。
后果(第 11 章 §11.2 的结论在这里得到完整解释):
StaticToolCallbackResolver 里没有 MCP 工具。因此:
spring.ai.tools.resolution.fallback.enabled=true(默认 false)对 MCP 工具无效------即使开启,也解析不到。- 开启 fallback 只会让非 MCP 的工具能在未挂载时被解析。MCP 工具必须显式挂载。
13.10.2 不一样二:必须显式挂载
Boot 的 ChatClientBuilderConfigurer(auto-configurations/models/chat/client/.../ChatClientBuilderConfigurer.java:36-76)只应用 ChatClientCustomizer / ChatClientBuilderCustomizer,不会自动把 ToolCallbackProvider Bean 挂到 ChatClient 上。
所以你必须自己写:
java
@Bean
ChatClient chatClient(ChatClient.Builder builder, SyncMcpToolCallbackProvider mcpTools) {
return builder
.defaultToolCallbacks(mcpTools) // ← 显式挂载
.build();
}
或者请求级:
java
chatClient.prompt()
.tools(mcpTools) // 或 .toolCallbacks(mcpTools)
.user("...")
.call();
忘了这一步的表现 :MCP 工具一个都不生效,而 McpToolCallbackAutoConfiguration 明明创建了 provider Bean。没有任何日志提示你"provider 未被使用"。
排查方法 :在 ToolCallingAdvisor 的 doBeforeCall 里打印 prompt.getOptions() 里的 toolCallbacks 数量。
13.10.3 不一样三:getToolCallbacks() 有网络往返
@Tool 的 MethodToolCallbackProvider.getToolCallbacks() 是纯内存 扫描(反射)。MCP 的是同步网络调用(13.4.2)。
这带来三个后果:
-
首次调用慢(N 个 server 串行)。
-
调用时机敏感。如果它在请求线程上被首次触发,会把延迟转嫁给用户。
-
每次工具循环都会调用吗? 不会------
ToolCallingManager.resolveToolDefinitions每轮都调(DefaultToolCallingManager第 11 章讲过),但resolveToolDefinitions读的是options.getToolCallbacks(),而那是启动时就挂载好的数组 ,不是每次调 provider。所以getToolCallbacks()只在挂载时调一次(+ 热更新时)。但如果每次请求都
chatClient.prompt().tools(mcpProvider),就会每次都调。
配方 :把 provider 挂在 defaultToolCallbacks(一次),而不是每次请求 .tools(...)。
13.10.4 与调用预算的关系
第 11 章讲的 ToolCallLimits(40 / 150)对 MCP 工具同样生效 ------因为它们最终都是 ToolCallback,走同一条 executeToolCalls 路径。
但有一个 MCP 特有的放大效应:一次 MCP 工具调用 = 一次网络往返 + server 侧可能再调 N 个外部 API。150 次 MCP 工具调用的代价远高于 150 次本地方法调用。
配方 :MCP 场景把 max-total-tool-calls 调低(例如 30),并把 on-limit-exceeded 设成 RETURN_ERROR_RESPONSE(让模型知道被限流而不是直接中断)。
yaml
spring:
ai:
tools:
limits:
max-calls-per-tool-default: 8
max-total-tool-calls: 30
on-limit-exceeded: RETURN_ERROR_RESPONSE
13.11 观测与 AOT:两个容易被忽略的空白
13.11.1 MCP 客户端没有自己的观测体系
一个可以验证的事实:mcp/ 目录下没有任何 Observation 相关代码。
shell
grep -rln "Observation" --include=*.java mcp/ | grep -v target
# (无输出)
这意味着什么:
| 环节 | 有没有 span |
|---|---|
工具执行(作为 ToolCallback) |
✅ spring.ai.tool(由 DefaultToolCallingManager 创建,第 12 章 §12.6) |
mcpClient.listTools() |
❌ 无 |
mcpClient.callTool(...) 的网络往返 |
❌ 无(span 包在外面,测的是 callback 总耗时,含 Spring AI 侧的转换) |
MCP 握手 initialize() |
❌ 无 |
| STDIO 子进程生命周期 | ❌ 无 |
后果 :你无法 从 trace 里区分"工具执行慢"是 Spring AI 的转换开销还是 MCP 网络往返。也看不到 listTools 的耗时(它在 getToolCallbacks() 里,通常在启动时或热更新时)。
补偿方案(三选一):
-
自定义
ToolCallback包装 ,在call()里自己开一个 span:java@Override public String call(String input, @Nullable ToolContext ctx) { Observation obs = Observation.createNotStarted("mcp.tool.call", () -> Map.of("tool", name), registry); return obs.observe(() -> this.delegate.call(input, ctx)); } -
用
McpClientCustomizer<SyncSpec>包住spec.requestTimeout(...)之外的东西------但 SDK 的 spec 没有暴露"请求拦截器",所以这条路走不通。 -
传输层加观测 :JDK HttpClient 可以用
McpSyncHttpClientRequestCustomizer打日志;WebFlux 可以用WebClient.Builder.filter(...)加 metrics filter。这是唯一能观测到 MCP 网络往返的办法。
13.11.2 AOT / Native Image
三处 hints 注册:
| 类 | 作用 |
|---|---|
McpClientAutoConfigurationRuntimeHints(client-common) |
注册 **.json 资源 + org.springframework.ai.mcp.client.common.autoconfigure 包下所有 JSON 注解类 |
McpClientAutoConfigurationRuntimeHints(client-httpclient / client-webflux,同名不同包) |
各自传输包的反射注册 |
McpHints |
注册 McpSchema 的所有内部类为反射类型 |
java
// mcp/common/.../aot/McpHints.java:60-70
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
var mcs = MemberCategory.values();
Set<TypeReference> typeReferences = AiRuntimeHints.findInnerClassesFor(McpSchema.class);
for (var tr : typeReferences) {
hints.reflection().registerType(tr, mcs);
}
}
McpSchema 的所有内部类全部注册------这是必要的,因为 MCP 协议的 JSON 反序列化要反射构造这些 record。
注意 McpClientAutoConfigurationRuntimeHints 有三个同名类(client-common、client-httpclient、client-webflux),分属不同包。搜代码时容易搞混。
客户端注解扫描也带 AOT 处理器(McpClientAnnotationScannerAutoConfiguration:57、:81-86):
java
@Bean
static ClientAnnotatedBeanFactoryInitializationAotProcessor clientAnnotatedBeanFactoryInitializationAotProcessor() {
return new ClientAnnotatedBeanFactoryInitializationAotProcessor(CLIENT_MCP_ANNOTATIONS);
}
它扫描的注解集合(:60-62):
java
private static final Set<Class<? extends Annotation>> CLIENT_MCP_ANNOTATIONS = Set.of(McpLogging.class,
McpSampling.class, McpElicitation.class, McpProgress.class, McpToolListChanged.class,
McpResourceListChanged.class, McpPromptListChanged.class);
注意这里面没有 @McpTool ------ 客户端只处理"sampling / elicitation / logging / progress / 变更通知"这些反向调用(server 调 client),工具注解是服务端的事(第 14 章)。
13.12 陷阱总表
| # | 陷阱 | 位置 | 后果 | 规避 |
|---|---|---|---|---|
| 1 | 两个 Starter 同时引入 → 客户端翻倍 | McpClientAutoConfiguration:155 |
工具列表翻倍、重名崩溃 | 只引一个 |
| 2 | 两个 McpToolNamePrefixGenerator Bean → 静默退回 noPrefix() |
McpToolCallbackAutoConfiguration:80-82 |
重名崩溃,无日志 | 保证唯一 Bean |
| 3 | 两个 McpToolFilter Bean → 静默全放行 |
:80 |
过滤失效 | 保证唯一 Bean |
| 4 | 两个 ToolContextToMcpMetaConverter Bean → 静默退回默认 |
:83-84 |
白名单失效 | 保证唯一 Bean |
| 5 | DefaultMcpToolNamePrefixGenerator 伪幂等 |
DefaultMcpToolNamePrefixGenerator:66-77 |
热更新后重名工具退化 → 从此每次请求都崩 | 自定义 generator 持久化映射 |
| 6 | format() 删除非法字符造成新冲突 |
McpToolUtils:117-125 |
get.weather 与 get weather 变同名 |
工具名只用 [a-zA-Z0-9_-] |
| 7 | prefixedToolName 超 64 取尾部 |
McpToolUtils:106-108 |
前缀被砍 → 可能与供应商截断叠加 | 工具名 ≤ 40 字符 |
| 8 | SYNC 有 title、ASYNC 没有 |
McpClientAutoConfiguration:163 vs :254-257 |
切换 type 后工具名变化 | 不要依赖废弃构造函数 |
| 9 | initialized=true 时握手失败即启动失败 |
:189-191、:280-282 |
本地没起 server 就跑不起来 | 开发环境设 initialized=false |
| 10 | requestTimeout 20s × N 轮 |
McpClientCommonProperties:63 |
工具循环总时长远超网关超时 | MCP 超时 ≪ 网关超时 |
| 11 | AsyncMcpToolCallbackProvider.getToolCallbacks() 用 .block() |
:155 |
WebFlux event loop 上抛 ISE | 启动时预热 |
| 12 | emitter 覆盖注解处理器的 toolsChangeConsumer |
:178 vs McpSyncClientConfigurer |
@McpToolListChanged 不生效 |
自己写 customizer 兼顾 |
| 13 | 热更新无去抖 | McpSyncToolsChangeEventEmmiter:42-46 |
server 频繁推 → 客户端被打爆 | 自定义去抖 customizer |
| 14 | AsyncMcpToolCallback 内部 .block() |
AsyncMcpToolCallback:131 |
绕过 advisor 直接用会崩 | 依赖 advisor 的 subscribeOn |
| 15 | McpError 硬抛、不转友好响应 |
SyncMcpToolCallback:136-144 |
整个模型交互失败 | 包一层 ToolCallback |
| 16 | isError=true 一律转成异常 |
:150-156 |
消息含 TextContent 的 toString() |
server 侧优先用返回值传可恢复错误 |
| 17 | 结果是 Content[] 的 JSON |
:157 |
图片 → base64 → 巨量 token | 只提取 TextContent |
| 18 | MCP 工具不在 resolver 里 | ToolCallingAutoConfiguration:96-99 |
resolution.fallback 对 MCP 无效 |
显式挂载 |
| 19 | Boot 不会自动挂载 provider | ChatClientBuilderConfigurer:57-60 |
忘了挂载 → 工具全不生效,无日志 | defaultToolCallbacks(...) |
| 20 | 零 client 的 provider 静默返回空 | SyncMcpToolCallbackProvider:300-301 |
工具一个都没有 | 检查 mcpClients 非空 |
| 21 | MCP 没有任何自有 span | mcp/ 全目录 |
看不到 listTools / 网络往返耗时 |
传输层加 filter/metrics |
| 22 | STDIO 用 JsonMapper.shared()、WebFlux 用 JsonMapper::new |
StdioTransportAutoConfiguration:79 / StreamableHttpWebFluxTransportAutoConfiguration:100 |
WebFlux 版可能丢失自定义 module | 显式提供 JsonMapper Bean |
| 23 | HttpClient.newBuilder() 每次新建且不关闭 |
StreamableHttpHttpClientTransportAutoConfiguration:132 |
无法精细控制连接池 | 用 request customizer 或换 WebFlux |
| 24 | 传输 builder customizer 无序 | :140-142 |
无法控制顺序 | 合并成一个 Bean |
| 25 | McpClientCustomizer 调 asyncHttpRequestCustomizer 会覆盖合并结果 |
Javadoc :92-94 |
前面的 customizer 全部失效 | 避免重复设置 |
| 26 | STDIO JSON 资源只读第一个顶层 key | McpStdioClientProperties:78 |
其余配置静默忽略 | 一个 JSON 只放一个顶层 key |
| 27 | SSE 全套 forRemoval |
三处 @Deprecated |
将来不可用 | 新项目用 Streamable HTTP |
| 28 | 只有 SSE 支持 ServiceConnection |
McpSseClientConnectionDetails:29 |
Testcontainers 无法直连 Streamable | 自己实现 ConnectionDetails |
13.13 本章小结
-
传输层换代已完成 :SSE 三处标
@Deprecated(since="2.0.0", forRemoval=true),Streamable HTTP 是方向,STDIO 仍是本地 server 的首选。两个 Starter 只差传输实现,互斥。 -
NamedClientMcpTransport是枢纽 :所有传输自动配置都产出List<NamedClientMcpTransport>,McpClientAutoConfiguration按 name 逐个建客户端。name 同时是clientInfo.title(仅 SYNC )、customizer 的区分键、以及McpToolsChangedEvent的连接名。 -
三套命名逻辑互不一致 :
noPrefix()(未净化)、DefaultMcpToolNamePrefixGenerator(format+alt_N_,不截断 )、McpToolUtils.prefixedToolName(含 64 字符尾部截断)。第 11 章那条 "truncated due to length limits" 的告警指向的是第三套------而它只被废弃构造函数使用。 -
DefaultMcpToolNamePrefixGenerator的伪幂等是本章最严重的发现 :它只在(client, server, tool)首次出现时消歧,且没有保存分配结果 ,之后一律短路返回format(tool.name())。于是重名工具在热更新后退化成同名 →validateToolCallbacks抛 ISE →invalidateCache保持 true → 从此每次请求都崩。生产环境必须自定义 generator。 -
MCP 工具被显式排除出
ToolCallbackResolver(按类名字符串匹配 + 递归父类),所以resolution.fallback对它无效,且 Boot 不会自动挂载 ------必须defaultToolCallbacks(mcpProvider)。 -
热更新链路有三个断点 :注解处理器与 emitter 争抢
toolsChangeConsumer(后者胜)、provider 忽略事件里带的 tools 而选择重新listTools()(无去抖)、以及失效后可能直接崩溃(第 4 条)。 -
异常语义在 2.0 收紧 :
McpError硬抛(不回喂模型),其他异常包ToolExecutionException;isError=true也一律转异常。想做优雅降级只能包一层ToolCallback。 -
MCP 客户端没有任何自有观测 span ------
listTools、网络往返、握手全部不可见。想观测只能在传输层(WebClient filter / HttpClient customizer)或自己包装 callback。
13.14 思考题
-
你的应用接了三个 MCP server,其中两个都提供
searchDocuments。用默认的DefaultMcpToolNamePrefixGenerator,首次启动会得到哪两个工具名?其中一个 server 推送list_changed之后会发生什么?请按 13.5.3 的推演写出完整过程。 -
为什么
McpClientCustomizer<McpClient.SyncSpec>用orderedStream()(有序),而McpClientCustomizer<Transport.Builder>用直接迭代(无序)?如果你需要保证某个传输自定义器最后执行,有几种办法? -
你定义的
McpToolNamePrefixGenerator没有生效,工具名是原始的getWeather。请列出三种可能的原因,并给出每种的验证命令。 -
McpToolUtils.format()把连字符换成下划线,但删除 其他非法字符。请构造两个不同的 MCP 工具名,使它们format后相同。再构造两个,format后不同但prefixedToolName截断后相同。 -
你的 MCP server 在 WebFlux 应用里用 STDIO 传输启动,
getToolCallbacks()首次调用时抛了IllegalStateException: block()/blockFirst()/blockLast() are blocking...。但你用的是SyncMcpToolCallbackProvider,它的getToolCallbacks()里没有.block()。请解释这个异常最可能的来源。 -
想把
tenantId传给 MCP server,你写了.toolContext(Map.of("tenantId", tid))。但在 server 侧request.meta()里看不到。请列出四个可能的原因。 -
为什么
AsyncMcpToolCallbackProvider叫 "Async" 却在两处阻塞(getToolCallbacks()的.block()、call()的.block())?它的"异步"到底体现在哪里?在什么场景下选 ASYNC 才有实际收益?
13.15 本章文件索引
| 文件 | 关键行 | 内容 |
|---|---|---|
mcp/common/.../SyncMcpToolCallbackProvider.java |
43 / 124-155 / 160-167 / 182-188 / 217-306 | provider、双检锁、事件监听、重名校验、Builder |
mcp/common/.../AsyncMcpToolCallbackProvider.java |
45 / 132-173 / 182-185 / 235-309 | async provider(.block())、Builder |
mcp/common/.../SyncMcpToolCallback.java |
47 / 68-71 / 93-104 / 111-158 / 229-240 | callback、废弃构造函数、原名 vs 前缀名、异常处理 |
mcp/common/.../AsyncMcpToolCallback.java |
106-150 | onErrorMap + contextWrite + .block() |
mcp/common/.../McpToolNamePrefixGenerator.java |
39-51 | 接口与 noPrefix() |
mcp/common/.../DefaultMcpToolNamePrefixGenerator.java |
49-85 | 伪幂等 bug 现场 |
mcp/common/.../McpToolUtils.java |
73-80 / 92-141 / 192-288 | TOOL_CONTEXT_MCP_EXCHANGE_KEY、prefixedToolName/format/shorten、服务端转换 |
mcp/common/.../McpToolFilter.java |
30-32 | 空 BiPredicate 接口 |
mcp/common/.../McpToolsChangedEvent.java |
30-50 | 事件 |
mcp/common/.../McpConnectionInfo.java |
33-104 | 连接信息 record(@Nullable initializeResult) |
mcp/common/.../ToolContextToMcpMetaConverter.java |
32-69 | 接口与两个工厂方法 |
mcp/common/.../customizer/McpClientCustomizer.java |
36-48 | 自定义器接口 |
mcp/common/.../aot/McpHints.java |
60-70 | McpSchema 内部类全反射注册 |
auto-configurations/mcp/.../McpClientAutoConfiguration.java |
113-115 / 145-198 / 254-257 / 313-326 | 名字拼接、SYNC 建客户端、title 不对称、生命周期 |
auto-configurations/mcp/.../McpToolCallbackAutoConfiguration.java |
50-54 / 68-102 / 104-122 | prefix Bean、provider Bean、条件 |
auto-configurations/mcp/.../StdioTransportAutoConfiguration.java |
72-85 | STDIO 传输 |
auto-configurations/mcp/.../McpSyncToolsChangeEventEmmiter.java |
32-48 | 事件发射(SYNC) |
auto-configurations/mcp/.../McpAsyncToolsChangeEventEmmiter.java |
41-47 | 事件发射(ASYNC) |
auto-configurations/mcp/.../NamedClientMcpTransport.java |
30-32 | 命名传输 record |
auto-configurations/mcp/.../McpSseClientConnectionDetails.java |
29-33 | ServiceConnection 抽象 |
auto-configurations/mcp/.../properties/McpClientCommonProperties.java |
34 / 63 / 74 / 79-91 | 配置前缀、20s 超时、type |
auto-configurations/mcp/.../properties/McpStdioClientProperties.java |
70-93 / 95-103 | JSON 资源读取(只读第一个 key)、两路合并 |
auto-configurations/mcp/.../properties/McpStreamableHttpClientProperties.java |
47-83 | streamable-http 配置 |
auto-configurations/mcp/.../properties/McpSseClientProperties.java |
59-60 / 84-89 | SSE 配置(已废弃) |
auto-configurations/mcp/.../configurer/McpSyncClientConfigurer.java |
--- | 聚合 customizer |
auto-configurations/mcp/.../annotations/McpClientAnnotationScannerAutoConfiguration.java |
60-62 / 64-86 | 客户端注解集合 |
auto-configurations/mcp/.../StreamableHttpHttpClientTransportAutoConfiguration.java |
107-166 | JDK HttpClient 传输 |
auto-configurations/mcp/.../StreamableHttpWebFluxTransportAutoConfiguration.java |
91-122 | WebClient 传输 |
auto-configurations/mcp/.../SseHttpClientTransportAutoConfiguration.java |
64 | 已废弃 |
auto-configurations/mcp/.../SseWebFluxTransportAutoConfiguration.java |
72 / 99-127 | 已废弃 |
auto-configurations/models/tool/.../ToolCallingAutoConfiguration.java |
96-108 | MCP provider 被排除出 resolver |
auto-configurations/models/chat/client/.../ChatClientBuilderConfigurer.java |
36-76 | 不会自动挂载 provider |
spring-ai-model/.../tool/support/ToolUtils.java |
106-116 | getDuplicateToolNames |
spring-ai-model/.../model/tool/ToolCallingChatOptions.java |
92-99 | 第二处重名校验 |
spring-ai-model/.../model/tool/DefaultToolCallingManager.java |
86-88 / 293-296 | 本章要解释的那条 WARN |
starters/spring-ai-starter-mcp-client/pom.xml |
24-44 | httpclient starter |
starters/spring-ai-starter-mcp-client-webflux/pom.xml |
27-46 | webflux starter |
spring-ai-docs/.../upgrade-notes.adoc |
130-149 / 1493-1600 | @McpTool 异常语义变更、MCP SDK 2.0 破坏性变更 |
13.16 复核命令
shell
cd D:/CODE/spring-ai-2.0.1
# 1) 证明 MCP 客户端没有任何自有观测
grep -rln "Observation" --include=*.java mcp/ | grep -v target
# 2) 伪幂等 bug 现场
sed -n '61,80p' mcp/common/src/main/java/org/springframework/ai/mcp/DefaultMcpToolNamePrefixGenerator.java
# 3) 64 字符截断与字符过滤
sed -n '92,141p' mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java
# 4) SYNC 有 title / ASYNC 无 title
sed -n '158,168p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java
sed -n '252,258p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java
# 5) MCP provider 被排除出 resolver
sed -n '96,108p' auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java
# 6) 三处 getIfUnique 静默降级
sed -n '68,102p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpToolCallbackAutoConfiguration.java
# 7) SSE 的 forRemoval 标记
grep -rn "forRemoval" --include=*.java auto-configurations/mcp/ | grep -v target
# 8) 热更新的两个争抢点
sed -n '170,186p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java
cat auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpSyncToolsChangeEventEmmiter.java
# 9) 被注释掉的 client 非空校验
sed -n '299,305p' mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java
# 10) 异常语义(McpError 硬抛)
sed -n '125,158p' mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java
# 11) 两个 Starter 的差异
grep -n "artifactId" starters/spring-ai-starter-mcp-client/pom.xml
grep -n "artifactId" starters/spring-ai-starter-mcp-client-webflux/pom.xml
# 12) STDIO JSON 只读第一个 key
sed -n '70,93p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/properties/McpStdioClientProperties.java
# 13) 第 11 章那条 WARN 的原文
sed -n '86,88p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
sed -n '290,297p' spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java
13.17 下一章预告
本章覆盖了 MCP 的客户端:传输怎么选、工具怎么聚合、名字怎么消歧、热更新怎么工作、以及它和 Spring AI 工具体系的三个"不一样"。
第 14 章转向服务端:
@McpTool/@McpArg/@McpToolParam怎么被扫成SyncToolSpecification,McpToolUtils.toSyncToolSpecification的内部构造。ReturnMode(TEXT / STRUCTURED / VOID)的差异与outputSchema的生成。- 服务端三种形态:STDIO / SSE / Streamable HTTP 的自动配置差异,以及 STATEFUL vs STATELESS (
McpServerAutoConfigurationvsMcpServerStatelessAutoConfiguration)这个 2.x 新增的分叉。 - MCP SDK 2.0 的入参校验默认开启 (
validateToolInputs)如何影响你的既有 server。 ToolCallbackConverterAutoConfiguration/StatelessToolCallbackConverterAutoConfiguration:怎么把 Spring AI 的ToolCallbackBean 直接暴露成 MCP 工具。- 服务端侧的异常语义(与 13.9.4 呼应):
RuntimeException→isError结果 vsMcpError/ 受检异常 /Error硬抛。
第 14 章:MCP 服务端------注解式实现、三种协议形态与状态管理。