Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突

Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突

第 11 章在讲 DefaultToolCallingManager 时留下过一条 WARN 文案没有展开:

vbnet 复制代码
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

(spring-ai-model/.../model/tool/DefaultToolCallingManager.java:86-88、:293-294)

这条告警同时提到了两件事:名字被截断 ,以及用 McpToolNamePrefixGenerator 自定义前缀逻辑。本章把这两个都讲透,顺带把 MCP 客户端的模块拓扑、三种传输、工具聚合与热更新一次说清。


13.0 本章的四句总纲

  1. 传输层已经换代。 SSE 的整套客户端自动配置在 2.0.0 被标记 @Deprecated(since = "2.0.0", forRemoval = true)(SseHttpClientTransportAutoConfiguration:64、SseWebFluxTransportAutoConfiguration:72、McpSseClientProperties:59),Streamable HTTP 是现在的方向;STDIO 依然是一等公民,且不在废弃名单上。
  2. MCP 工具被显式排除出 ToolCallbackResolver。 ToolCallingAutoConfiguration 用 isMcpToolCallbackProvider 按类名字符串 过滤掉了两个 MCP provider(:96-99、:102-108),所以 spring.ai.tools.resolution.fallback.enabled=true 对 MCP 工具完全无效------必须显式挂载。
  3. DefaultMcpToolNamePrefixGenerator 的"幂等"是假的。 它只在 (client, server, tool) 组合首次出现时 做消歧,之后一律短路返回 format(tool.name())。而它没有把首次分配的名字存下来 ,所以重名工具在第二次刷新(工具变更事件触发)后会退化成同名,被 validateToolCallbacks 直接打成 IllegalStateException。
  4. 存在两套互不相干的命名逻辑。 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 有三个用途:

  1. 组成 clientInfo 的 title(sync 分支)与 name(两个分支);
  2. 作为 McpClientCustomizer.customize(name, ...) 的第一个参数------你的自定义器靠它区分"这是哪个 server 的连接";
  3. 作为 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 的两个硬伤:

  1. 每个连接 fork 一个子进程 。10 个 MCP server = 10 个进程。进程崩溃后客户端不会自动重启(StdioClientTransport 的生命周期由 SDK 管理)。
  2. 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 McpClientCustomizer beans run, so a customizer that calls asyncHttpRequestCustomizer(...) 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]);
}

五个要点:

  1. 双检锁(DCL) :外层无锁读 volatile invalidateCache,内层加锁后再查一次。标准写法。
  2. mcpClient.listTools() 是同步网络调用。 首次调用会阻塞到所有 MCP server 响应。N 个 server 串行(stream 是顺序的)→ 最坏 N × 20s。
  3. connectionInfo(mcpClient) 被调用了两次 (一次给 filter、一次给 prefix generator),每次都新建一个 McpConnectionInfo(:169-175)。而 McpConnectionInfo.build() 会 Assert.state 两个非空(:98-99)。
  4. validateToolCallbacks 在缓存写入前执行 ,失败则抛异常、invalidateCache 保持 true → 下一次调用会重试(而不是缓存一个失败状态)。这是对的。
  5. 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()? 两处:

  1. 所有已废弃的构造函数 (SyncMcpToolCallbackProvider:67、AsyncMcpToolCallbackProvider:69)。
  2. 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 的 @ConditionalOnMissingBean Bean 是单例,而两个 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("-", "_");
}

两步:先删非法字符,再把 - 换成 _。

注意正则的两个细节:

  1. 支持中文 。白名单里显式列了 \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} 不含全角拉丁)→ 被删除。

  2. 被"删除"而不是"替换",这是冲突的来源:

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。

三个细节:

  1. toLowerCase(Locale.ROOT) ------ 用了显式 Locale,避免土耳其语 I 问题。✔
  2. word.charAt(0) ------ 中文字符取第一个字。天气查询 → 天。不缩写(因为 format 已保证它合法)。
  3. 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 取尾部 是 是(纯函数)

推荐:

  1. 生产环境务必自定义 McpToolNamePrefixGenerator (方案 A),并且确保容器里只有一个 该类型的 Bean(否则 getIfUnique 静默退回 noPrefix())。
  2. 不要用废弃的 public 构造函数 ------它们绑定了 noPrefix() + prefixedToolName 的旧行为。
  3. MCP server 侧的工具名控制在 40 字符内、只用 [a-zA-Z0-9_-],中文可用但要避开中文标点。
  4. 开启 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.listChanged capability,否则客户端不会收到。

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();
	}
}

默认转换器做两件过滤:

  1. 剔除 exchange key (McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY = "exchange",McpToolUtils:80)。这个 key 在服务端 用来存放 McpSyncServerExchange------一个不可序列化的活对象。如果不剔除,序列化进 MCP meta 会直接炸。

    这是第 14 章要讲的服务端细节在客户端的对应防护:客户端要防止自己(或某个 advisor)往 ToolContext 里塞的 exchange 被发出去。

  2. 剔除 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();

三点差异:

  1. 用 onErrorMap(predicate, mapper) 而非 try-catch。 predicate 是 !(e instanceof McpError)------即只对非 McpError 做转换,语义与 sync 版一致。
  2. .contextWrite(ctx -> ctx.putAll(ToolCallReactiveContextHolder.getContext())) ------ 这是第 11/12 章讲过的观测父子桥。它把 Reactor Context 里的 observation 传播到 MCP 调用链上,让工具 span 能挂在正确的父节点下。
  3. .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);
}

两个值得吐槽的实现细节:

  1. 用类名字符串比较 (type.getType().getTypeName().equals(...)),而不是 instanceof。这是为了避免 spring-ai-mcp 成为 spring-ai-autoconfigure-model-tool 的编译期依赖(可选依赖)。
  2. 递归检查父类 (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)。

这带来三个后果:

  1. 首次调用慢(N 个 server 串行)。

  2. 调用时机敏感。如果它在请求线程上被首次触发,会把延迟转嫁给用户。

  3. 每次工具循环都会调用吗? 不会------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() 里,通常在启动时或热更新时)。

补偿方案(三选一):

  1. 自定义 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));
    }
  2. 用 McpClientCustomizer<SyncSpec> 包住 spec.requestTimeout(...) 之外的东西------但 SDK 的 spec 没有暴露"请求拦截器",所以这条路走不通。

  3. 传输层加观测 :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 本章小结

  1. 传输层换代已完成 :SSE 三处标 @Deprecated(since="2.0.0", forRemoval=true),Streamable HTTP 是方向,STDIO 仍是本地 server 的首选。两个 Starter 只差传输实现,互斥。

  2. NamedClientMcpTransport 是枢纽 :所有传输自动配置都产出 List<NamedClientMcpTransport>,McpClientAutoConfiguration 按 name 逐个建客户端。name 同时是 clientInfo.title(仅 SYNC )、customizer 的区分键、以及 McpToolsChangedEvent 的连接名。

  3. 三套命名逻辑互不一致 :noPrefix()(未净化)、DefaultMcpToolNamePrefixGenerator(format + alt_N_,不截断 )、McpToolUtils.prefixedToolName(含 64 字符尾部截断)。第 11 章那条 "truncated due to length limits" 的告警指向的是第三套------而它只被废弃构造函数使用。

  4. DefaultMcpToolNamePrefixGenerator 的伪幂等是本章最严重的发现 :它只在 (client, server, tool) 首次出现时消歧,且没有保存分配结果 ,之后一律短路返回 format(tool.name())。于是重名工具在热更新后退化成同名 → validateToolCallbacks 抛 ISE → invalidateCache 保持 true → 从此每次请求都崩。生产环境必须自定义 generator。

  5. MCP 工具被显式排除出 ToolCallbackResolver (按类名字符串匹配 + 递归父类),所以 resolution.fallback 对它无效,且 Boot 不会自动挂载 ------必须 defaultToolCallbacks(mcpProvider)。

  6. 热更新链路有三个断点 :注解处理器与 emitter 争抢 toolsChangeConsumer(后者胜)、provider 忽略事件里带的 tools 而选择重新 listTools()(无去抖)、以及失效后可能直接崩溃(第 4 条)。

  7. 异常语义在 2.0 收紧 :McpError 硬抛(不回喂模型),其他异常包 ToolExecutionException;isError=true 也一律转异常。想做优雅降级只能包一层 ToolCallback。

  8. MCP 客户端没有任何自有观测 span ------listTools、网络往返、握手全部不可见。想观测只能在传输层(WebClient filter / HttpClient customizer)或自己包装 callback。


13.14 思考题

  1. 你的应用接了三个 MCP server,其中两个都提供 searchDocuments。用默认的 DefaultMcpToolNamePrefixGenerator,首次启动会得到哪两个工具名?其中一个 server 推送 list_changed 之后会发生什么?请按 13.5.3 的推演写出完整过程。

  2. 为什么 McpClientCustomizer<McpClient.SyncSpec> 用 orderedStream()(有序),而 McpClientCustomizer<Transport.Builder> 用直接迭代(无序)?如果你需要保证某个传输自定义器最后执行,有几种办法?

  3. 你定义的 McpToolNamePrefixGenerator 没有生效,工具名是原始的 getWeather。请列出三种可能的原因,并给出每种的验证命令。

  4. McpToolUtils.format() 把连字符换成下划线,但删除 其他非法字符。请构造两个不同的 MCP 工具名,使它们 format 后相同。再构造两个,format 后不同但 prefixedToolName 截断后相同。

  5. 你的 MCP server 在 WebFlux 应用里用 STDIO 传输启动,getToolCallbacks() 首次调用时抛了 IllegalStateException: block()/blockFirst()/blockLast() are blocking...。但你用的是 SyncMcpToolCallbackProvider,它的 getToolCallbacks() 里没有 .block()。请解释这个异常最可能的来源。

  6. 想把 tenantId 传给 MCP server,你写了 .toolContext(Map.of("tenantId", tid))。但在 server 侧 request.meta() 里看不到。请列出四个可能的原因。

  7. 为什么 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 (McpServerAutoConfiguration vs McpServerStatelessAutoConfiguration)这个 2.x 新增的分叉。
  • MCP SDK 2.0 的入参校验默认开启 (validateToolInputs)如何影响你的既有 server。
  • ToolCallbackConverterAutoConfiguration / StatelessToolCallbackConverterAutoConfiguration:怎么把 Spring AI 的 ToolCallback Bean 直接暴露成 MCP 工具。
  • 服务端侧的异常语义(与 13.9.4 呼应):RuntimeException → isError 结果 vs McpError / 受检异常 / Error 硬抛。

第 14 章:MCP 服务端------注解式实现、三种协议形态与状态管理。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 11 章 工具调用(下):ToolCallingManager、ToolCallingAdvisor与调用预算
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 17 章 向量存储(下):实现剖析,从 PgVector 到 S3 Vectors
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor流水线与 Query体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 09 章 工具调用(上):`@Tool` 注解、ToolDefinition 与 JSON Schema 生成
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 02 章 核心抽象层:`Model` / `StreamingModel` 与 ChatModel 体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 01 章 全景:模块拓扑、依赖层次与设计哲学
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 05 章 Advisor 架构:接口体系、栈式链与 context 传播
java·人工智能·开源