Spring AI 2.0.1 深度解析-第 14 章 MCP 服务端(上):注解式实现与工具来源
第 13 章把 MCP 客户端 拆完了。那一章里有一句话是站着客户端立场说的:"工具名是客户端加前缀后拼出来的,服务端给什么名字客户端就用什么名字。"本章反过来问:服务端那个名字是从哪来的?
答案并不像想象中那么干净。Spring AI 2.0.1 的服务端有两条完全独立的工具来源 :一条是注解扫描(
@McpTool),一条是ToolCallback转换(Spring AI 的@Tool)。它们生成的工具名走不同的规则、做不同的去重、支持不同的能力(outputSchema只有前者有),最后在一个List<...>Bean 里拼接起来,中间没有任何跨来源的冲突检查。这一章把服务端从"一个方法上加个注解"开始,一路讲到"HTTP 请求打进来后会话是怎么被记住或忘掉的"。
14.0 本章的五句总纲
-
服务端有两条工具来源,且互不知情。 注解路径由
McpServerSpecificationFactoryAutoConfiguration产出List<SyncToolSpecification>;ToolCallback路径由ToolCallbackConverterAutoConfiguration产出同类型 的另一个 Bean。McpServerAutoConfiguration用ObjectProvider<List<SyncToolSpecification>>+flatMap把两者拼起来(McpServerAutoConfiguration:110、:138-139)。拼接前,ToolCallback路径做了按名去重(Collectors.toMap(..., (existing, replacement) -> existing),ToolCallbackConverterAutoConfiguration:64-67),注解路径没有任何去重,两个来源之间也没有。 -
protocol的字段默认值和实际生效值是相反的。McpServerProperties.protocol字段初始化为STREAMABLE(McpServerProperties:100),但决定用哪套自动配置的是McpServerAutoConfiguration.NonStatelessServerCondition:其中 SSE 分支写的是matchIfMissing = true(:349-350),STREAMABLE 分支写的是matchIfMissing = false(:355-356)。于是在不显式配置spring.ai.mcp.server.protocol时,胜出的是 SSE 分支------McpServerSseWebMvcAutoConfiguration(已@Deprecated(since = "2.0.0", forRemoval = true),:76)才是默认装配的那一个。 -
STATELESS 是一整套降级,不是一个开关。
McpServerStatelessAutoConfiguration把所有capabilities的变化通知硬编码为 false (tools(false)、resources(false, false)、prompts(false),:94、:107、:131),连rootsChangeConsumer/McpSyncServerCustomizer都不注入。相应地,SyncStatelessMcpToolProvider会静默丢弃 任何带"双向参数"(McpSyncServerExchange/McpAsyncServerExchange/McpSyncRequestContext/McpAsyncRequestContext)的@McpTool方法(McpPredicates:81-107、SyncStatelessMcpToolProvider:75)。 -
Schema 生成严重依赖
-parameters编译参数。McpJsonSchemaGenerator.internalGenerateFromMethodArguments用parameter.getName()作为属性名(:156),AbstractMcpToolMethodCallback.buildMethodArguments用parameter.getName()作为入参绑定的 key(:139)。没有-parameters时 Java 反射只能拿到arg0/arg1,Schema 与绑定会同时 失效,而且不报错------工具只会安静地收到一堆null。 -
服务端的
ASYNC不是"非阻塞"。 分两个层面:注解路径要求方法返回Mono/Flux/Publisher(McpPredicates.filterNonReactiveReturnTypeMethod,:55-66),是真的响应式;而ToolCallback路径的McpToolUtils.toAsyncToolSpecification是Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())包住一个阻塞 调用(McpToolUtils:390-396)------把阻塞方法挪到 boundedElastic 线程池,线程池打满照样雪崩。
关于第 13 章预告里提到的
validateToolInputs:那是 MCP SDK 侧的能力。本章核对过------Spring AI 2.0.1 全仓库对validateToolInputs零命中 ,既没有对应配置属性,也没有在McpServer.*Specification上调用它。按本书体例,看不见源码的东西不做断言,本章只在 14.9.4 说明"Spring AI 侧没有为它提供开关"。
14.1 模块拓扑与 Starter 选型
14.1.1 目录与 artifact 的对应关系
服务端的源码分布在四个地方:
| 源码目录 | artifactId | 承担的职责 |
|---|---|---|
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common |
同名 | 传输无关的核心装配:McpServerAutoConfiguration、McpServerStatelessAutoConfiguration、McpServerJsonMapperAutoConfiguration、两个 ToolCallbackConverter*AutoConfiguration、annotations/ 下三个注解相关配置、五个 properties 类 |
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc |
同名 | WebMVC 传输装配:SSE / Streamable / Stateless 三套 + RouterFunction Bean |
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux |
同名 | WebFlux 传输装配:同上三套 |
mcp/mcp-annotations |
spring-ai-mcp-annotations |
注解定义 + 方法回调 + Provider + Spring 集成(扫描 / AOT) |
mcp/common |
spring-ai-mcp |
McpToolUtils(ToolCallback → ToolSpecification 的转换全在这里) |
mcp/transport/mcp-spring-webmvc |
mcp-spring-webmvc |
WebMvcSseServerTransportProvider / WebMvcStreamableServerTransportProvider / WebMvcStatelessServerTransport |
mcp/transport/mcp-spring-webflux |
mcp-spring-webflux |
对应的三个 WebFlux 版本 |
和客户端一样,common 模块是 webmvc/webflux 两个自动配置模块的编译期依赖(spring-ai-autoconfigure-mcp-server-webmvc/pom.xml:22、...-webflux/pom.xml:22 都引了 spring-ai-autoconfigure-mcp-server-common),所以不需要手工再加一次。
14.1.2 三个 Starter 的依赖构成
xml
<!-- 1) 纯 STDIO,不带任何 web 依赖 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
<!-- 传递进来:spring-boot-starter
+ spring-ai-autoconfigure-mcp-server-common
+ spring-ai-mcp
+ spring-ai-mcp-annotations -->
<!-- 2) WebMVC -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<!-- 传递进来:spring-boot-starter-web
+ spring-ai-autoconfigure-mcp-server-webmvc(→ common)
+ mcp-spring-webmvc -->
<!-- 3) WebFlux -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webflux</artifactId>
</dependency>
<!-- 传递进来:spring-boot-starter-webflux
+ spring-ai-autoconfigure-mcp-server-webflux(→ common)
+ mcp-spring-webflux -->
(starters/spring-ai-starter-mcp-server/pom.xml、...-webmvc/pom.xml、...-webflux/pom.xml)
三个 Starter 都不是互斥的 ,但同时引入 webmvc 与 webflux 会撞车:McpServerSseWebMvcAutoConfiguration 与 McpServerSseWebFluxAutoConfiguration 都注册 RouterFunction Bean,且前者的 @ConditionalOnMissingBean(McpServerTransportProvider.class)(:74)只在没有 McpServerTransportProvider 时才生效------两个都注册 transport 时,谁先被处理谁赢,另一边静默退场。这一条和客户端 httpclient / webflux 两个 Starter 的互斥关系(第 13 章 13.1.3)是同一种味道,但服务端没有文档层面的硬性警告。
14.1.3 八个自动配置类的清单与顺序
ini
spring-ai-autoconfigure-mcp-server-common
└── META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
├── McpServerAutoConfiguration ← 有状态服务端(SSE / STREAMABLE)
├── McpServerJsonMapperAutoConfiguration ← mcpServerJsonMapper
├── ToolCallbackConverterAutoConfiguration ← ToolCallback → 有状态 spec
├── McpServerStatelessAutoConfiguration ← 无状态服务端(STATELESS)
├── StatelessToolCallbackConverterAutoConfiguration ← ToolCallback → 无状态 spec
├── annotations.McpServerSpecificationFactoryAutoConfiguration ← 注解 → 有状态 spec
├── annotations.StatelessServerSpecificationFactoryAutoConfiguration ← 注解 → 无状态 spec
└── annotations.McpServerAnnotationScannerAutoConfiguration ← BeanPostProcessor + AOT
spring-ai-autoconfigure-mcp-server-webmvc / -webflux
├── McpServerSseWebMvcAutoConfiguration @Deprecated(forRemoval=true)
├── McpServerStreamableHttpWebMvcAutoConfiguration
└── McpServerStatelessWebMvcAutoConfiguration
顺序上有两处显式声明:
- 三个传输配置都写了
@AutoConfiguration(before = McpServerAutoConfiguration.class)(如McpServerStreamableHttpWebMvcAutoConfiguration:44),注释说得很直白:"McpServerAutoConfiguration defines a low priorityMcpServerTransportProviderBasebean and this conf should have priority"------就是为了让传输层的 Bean 先占位 ,从而把McpServerAutoConfiguration里那个兜底的 STDIO transport 挤掉。 - 两个 spec factory 都写了
@AutoConfiguration(after = McpServerAnnotationScannerAutoConfiguration.class)(McpServerSpecificationFactoryAutoConfiguration:42、StatelessServerSpecificationFactoryAutoConfiguration:43),因为前者要消费后者的ServerMcpAnnotatedBeans。
14.2 服务端启动的三条装配链
一个 MCP 服务端能跑起来,需要三样东西按序就位。把它们想成三条链,读代码时就不会迷路。
14.2.1 链一:传输(transport)
ini
spring.ai.mcp.server.stdio=false 且 protocol=SSE|STREAMABLE
└─> McpServerStdioDisabledCondition 通过
├─ protocol=SSE → McpServerSseWebMvcAutoConfiguration
│ └─ WebMvcSseServerTransportProvider + RouterFunction
├─ protocol=STREAMABLE → McpServerStreamableHttpWebMvcAutoConfiguration
│ └─ WebMvcStreamableServerTransportProvider + RouterFunction
└─ protocol=STATELESS → McpServerStatelessWebMvcAutoConfiguration
└─ WebMvcStatelessServerTransport + RouterFunction
(以上都没命中时)
└─> McpServerAutoConfiguration.stdioServerTransport() ← @ConditionalOnMissingBean 兜底
└─ new StdioServerTransportProvider(...)
兜底那一行值得单独看(McpServerAutoConfiguration:91-96):
java
@Bean
@ConditionalOnMissingBean
public McpServerTransportProviderBase stdioServerTransport(
@Qualifier("mcpServerJsonMapper") JsonMapper mcpServerJsonMapper) {
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(mcpServerJsonMapper));
}
注意它没有任何 @ConditionalOnProperty(name = "stdio") 。也就是说:只要容器里没有别的 McpServerTransportProviderBase Bean(比如你把 protocol 配成了 STREAMABLE 但 classpath 上没有 web 传输实现),你就会得到一个 STDIO 服务端,而不是报错。这在排查"配了 HTTP 端口但服务没监听"时非常坑。
14.2.2 链二:服务端实例(server)
McpServerAutoConfiguration.mcpSyncServer(...)(:104-217)与 mcpAsyncServer(...)(:227-341)是同一段逻辑的同步/异步双份。以 SYNC 为例,它的执行顺序是:
- 构造
serverInfo:Implementation.builder(name, version).build(),取spring.ai.mcp.server.name(默认mcp-server)与.version(默认1.0.0)(:118-120、McpServerProperties:72、:77)。 - 按 transport 类型分派 :
transportProvider instanceof McpStreamableServerTransportProvider→McpServer.sync((McpStreamableServerTransportProvider) t),否则McpServer.sync((McpServerTransportProvider) t)(:122-129)。这是个instanceof分派,说明有状态服务端对两种传输的支持是 SDK 层面的两套Specification。 - 按 capability 装配四类规格 :tools / resources / resourceTemplates / prompts / completions,每一类都是"
capabilities开关为真 → 往capabilitiesBuilder上登记 →ObjectProvider里取出所有List<...>并flatMap拍平 → 非空则serverBuilder.xxx(...)"(:132-200)。 roots变更处理器 :rootsChangeConsumers.ifAvailable(...)(:202-207)。- 收尾三件套 :
capabilities(capabilitiesBuilder.build())、instructions(...)、requestTimeout(...)(:209-213)。 - 自定义钩子 :
mcpSyncServerCustomizer.ifPresent(customizer -> customizer.customize(serverBuilder))(:214)。 build()。
注意第 3 步里 resources 与 resourceTemplates 共用同一个开关 capabilities.isResource(),且重复调用了两次 capabilitiesBuilder.resources(false, changeNotification) (:151 与 :164)------第二次调用是幂等的,但它意味着 subscribe 能力恒为 false (第一个参数硬编码 false)。也就是说 Spring AI 的自动配置不支持 resources.subscribe,无论你怎么配。
14.2.3 链三:规格来源(spec)
ObjectProvider<List<SyncToolSpecification>> tools 会拿到所有 类型为 List<McpServerFeatures.SyncToolSpecification> 的 Bean。在默认配置下有两个:
| 来源 | 配置类 | Bean 方法 | 是否有按名去重 |
|---|---|---|---|
注解(@McpTool) |
McpServerSpecificationFactoryAutoConfiguration$SyncServerSpecificationConfiguration |
toolSpecs(ServerMcpAnnotatedBeans)(:86-91) |
无 |
ToolCallback(@Tool / 方法 / Function) |
ToolCallbackConverterAutoConfiguration |
syncTools(...)(:46-57) |
有 (toMap 保首个) |
STATELESS 下对应的是 StatelessServerSpecificationFactoryAutoConfiguration 与 StatelessToolCallbackConverterAutoConfiguration,产出 McpStatelessServerFeatures.SyncToolSpecification(不同的类型,所以不会和有状态的混在一起)。
无状态与有状态之间由 @Conditional 严格互斥:
ToolCallbackConverterAutoConfiguration:@Conditional({ ToolCallbackConverterCondition, McpServerAutoConfiguration.NonStatelessServerCondition })(:42-43)StatelessToolCallbackConverterAutoConfiguration:@Conditional({ McpServerStdioDisabledCondition, EnabledStatelessServerCondition, ToolCallbackConverterCondition })(:42-44)
STATELESS 额外要求 McpServerStdioDisabledCondition------STATELESS 与 STDIO 不能共存,这是符合直觉的(STDIO 天然是一对一长连接)。
14.2.4 一张完整的启动时序图
sql
① BeanPostProcessor 阶段
ServerAnnotatedMethodBeanPostProcessor.postProcessAfterInitialization
→ 对每个 bean 做 scan(AopUtils.getTargetClass(bean))
→ 有 @McpTool/@McpResource/@McpPrompt/@McpComplete 的方法
→ ServerMcpAnnotatedBeans.addMcpAnnotatedBean(bean, foundAnnotations)
② AOT 阶段(仅 native 构建)
ServerAnnotatedBeanFactoryInitializationAotProcessor.processAheadOfTime
→ 为命中类注册 MemberCategory.values()
→ 为 metaProvider 属性引用的类注册 INVOKE_DECLARED_CONSTRUCTORS
③ spec 生成
SyncMcpAnnotationProviders.toolSpecifications(beans)
→ new SpringAiSyncToolProvider(beans).getToolSpecifications()
→ 过滤 @McpTool → 过滤 reactive → 过滤双向参数(仅 stateless)→ 按方法名排序
→ McpJsonSchemaGenerator.generateForMethodInput(method)
→ McpSchema.Tool.builder(name, jsonMapper, inputSchema)...
→ new SyncMcpToolMethodCallback(returnMode, method, bean)
ToolCallbackUtils.aggregateToolCallbacks(...) → McpToolUtils.toSyncToolSpecification(callback, mimeType)
④ server 构建
McpServerAutoConfiguration.mcpSyncServer(...)
→ ObjectProvider<List<SyncToolSpecification>> 全部取出 → flatMap → serverBuilder.tools(...)
→ capabilitiesBuilder.build() → serverBuilder.build()
⑤ transport 注册路由
RouterFunction<ServerResponse> Bean → Spring MVC/WebFlux 接管 /mcp
14.3 注解扫描:BeanPostProcessor + AOT 两条腿
14.3.1 扫描器的三个 Bean
McpServerAnnotationScannerAutoConfiguration(:46-107)只做三件事:
java
private static final Set<Class<? extends Annotation>> SERVER_MCP_ANNOTATIONS = Set.of(
McpTool.class, McpResource.class, McpPrompt.class, McpComplete.class); // :54-55
@Bean @ConditionalOnMissingBean
public ServerMcpAnnotatedBeans serverAnnotatedBeanRegistry() { ... } // :57-61
@Bean @ConditionalOnMissingBean
public static ServerAnnotatedMethodBeanPostProcessor serverAnnotatedMethodBeanPostProcessor(...) // :63-68
@Bean
public static ServerAnnotatedBeanFactoryInitializationAotProcessor ...() // :70-73
两个 static 的 Bean 方法是必须的:BeanPostProcessor 与 BeanFactoryInitializationAotProcessor 都属于基础设施 Bean ,必须早于普通 Bean 实例化,用 static 工厂方法可以避免其所在配置类被过早(且可能不完整地)初始化。这是 Spring 框架层的老规矩,这里遵守得很标准。
对比客户端的 CLIENT_MCP_ANNOTATIONS(McpClientAnnotationScannerAutoConfiguration:60)是 {McpLogging, ...}------服务端与客户端的注解集合完全不相交 。也就是说 @McpTool 在客户端侧不会被扫,@McpLogging 在服务端侧不会被扫。如果同一个应用既是 MCP 服务端又是客户端(第 13 章 13.1 提到的"服务端转发下游工具"场景就是),两套扫描器会各自注册自己的 BPP,互不干扰。
14.3.2 BeanPostProcessor 的行为
java
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class<?> beanClass = AopUtils.getTargetClass(bean); // 处理代理
Set<Class<? extends Annotation>> foundAnnotations = scan(beanClass);
if (!foundAnnotations.isEmpty()) {
this.registry.addMcpAnnotatedBean(bean, foundAnnotations);
}
return bean;
}
(AbstractAnnotatedMethodBeanPostProcessor:44-54)
三个细节:
AopUtils.getTargetClass(bean)而不是bean.getClass()------被@Transactional/@Cacheable代理过的 Bean 也能正确扫到目标类上的注解。- 注册的是 bean 实例 (
addMcpAnnotatedBean(bean, ...))而不是 Class。后续SyncMcpToolMethodCallback反射调用时用的是这个实例(:142),所以工具方法里的依赖注入是正常生效的,且单例语义与 Spring 一致。 scan用的是AnnotationUtils.findAnnotation(AnnotatedMethodDiscovery:40),支持元注解 (findAnnotation会沿注解的注解向上找)。这意味着你可以自定义一个@MyMcpTool,只要它自己被@McpTool标注(@Target(ANNOTATION_TYPE)在McpTool:34是允许的),就能被扫到。
14.3.3 scan 的两个盲区
java
protected Set<Class<? extends Annotation>> scan(Class<?> beanClass) {
Set<Class<? extends Annotation>> foundAnnotations = new HashSet<>();
ReflectionUtils.doWithMethods(beanClass, method -> {
this.targetAnnotations.forEach(annotationType -> {
if (AnnotationUtils.findAnnotation(method, annotationType) != null) {
foundAnnotations.add(annotationType);
}
});
});
return foundAnnotations;
}
(AnnotatedMethodDiscovery:34-46)
盲区一:doWithMethods 只遍历 beanClass 自身及其父类/接口 ------这是对的。但注意它遍历的是 AopUtils.getTargetClass(bean)(即用户类 ),而后续真正生成 spec 时用的是 AnnotationProviderUtil.beanMethods(bean):
java
Method[] methods = ReflectionUtils.getUniqueDeclaredMethods(
AopUtils.isAopProxy(bean) ? AopUtils.getTargetClass(bean) : bean.getClass());
methods = Stream.of(methods).filter(ReflectionUtils.USER_DECLARED_METHODS::matches).toArray(Method[]::new);
(AnnotationProviderUtil:38-42)
getUniqueDeclaredMethods + USER_DECLARED_METHODS 会剔除桥接方法与合成方法 ,这一步是必要的(否则 CGLIB 代理或泛型覆写会产生重复方法)。但它同时也意味着:如果你把 @McpTool 写在接口方法上、实现类里没有重新标注 ,method.isAnnotationPresent(McpTool.class) 判断(注意用的是 isAnnotationPresent,SyncMcpToolProvider:70)会失败------因为 getUniqueDeclaredMethods 返回的是实现类的方法对象,而 isAnnotationPresent 不做继承查找。
这是个真实的不对称:
AnnotatedMethodDiscovery.scan用findAnnotation(会沿继承链找),SyncMcpToolProvider的过滤用isAnnotationPresent(不找)。结果是"Bean 被注册进了ServerMcpAnnotatedBeans,但一个工具都没生成",且只会在日志里留下一句No tool methods found in the provided tool objects(SyncMcpToolProvider:152-156)。结论:把@McpTool写在实现类的方法上,不要写在接口上。
盲区二:scan 遍历的是方法 ,不是类。@McpTool 的 @Target 本来就是 {METHOD, ANNOTATION_TYPE}(McpTool:34),所以这条没问题;但这也说明------@McpTool 所在的类必须是一个 Spring Bean ,否则 BPP 根本见不到它。写在 new 出来的对象上、写在工具类静态方法所属的类上(如果那个类不是 Bean)都不会生效。
14.3.4 AOT:为 native image 注册反射
java
@Override
public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
List<Class<?>> types = new ArrayList<>();
Set<Class<?>> metaProviderTypes = new LinkedHashSet<>();
for (String beanName : beanFactory.getBeanDefinitionNames()) {
Class<?> beanClass = beanFactory.getType(beanName);
if (beanClass == null) { continue; }
Set<Class<? extends Annotation>> classes = this.scan(beanClass);
if (!classes.isEmpty()) {
types.add(beanClass);
collectMetaProviderTypes(beanClass, metaProviderTypes);
}
}
return (generationContext, beanFactoryInitializationCode) -> {
RuntimeHints runtimeHints = generationContext.getRuntimeHints();
for (Class<?> typeReference : types) {
runtimeHints.reflection().registerType(typeReference, MemberCategory.values());
}
for (Class<?> metaProviderType : metaProviderTypes) {
runtimeHints.reflection().registerType(metaProviderType, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
}
};
}
(AbstractAnnotatedMethodBeanFactoryInitializationAotProcessor:54-84)
它注册的是 MemberCategory.values()(全部成员类别:构造、方法、字段),粒度很粗但安全。只注册了"含注解方法的 Bean 类"本身 ,没有注册这些方法的参数类型与返回类型 ------而 McpJsonSchemaGenerator 在 native 下要用 victools 反射遍历这些类型生成 Schema。这是一个潜在的 native 坑:复杂的入参/出参 DTO 需要你自己补 @RegisterReflectionForBinding 或 RuntimeHintsRegistrar。
collectMetaProviderTypes(:93-106)是个容易忽略的细节:它读出注解上的 metaProvider 属性,把那个类注册为可反射构造------因为 MetaUtils.getMeta 会用无参构造反射实例化它(MetaUtils:70-78)。如果你自定义了 MetaProvider 却忘了它会被反射实例化,在 native 下你会拿到 IllegalArgumentException: Required no-arg constructor not found(MetaUtils 注释里明确写了这个异常) ------而且这个异常发生在生成 spec 时,也就是应用启动期,表现为启动直接失败。
14.3.5 一段最小可运行的服务端
java
@Service
public class WeatherService {
@McpTool(name = "get_weather", description = "按城市查询当前天气")
public String getWeather(@McpToolParam(description = "城市名,如 Beijing") String city) {
return city + ": 晴,26℃";
}
@McpTool(name = "get_forecast",
description = "查询未来若干天的天气预报",
generateOutputSchema = true)
public Forecast getForecast(@McpToolParam(description = "城市名") String city,
@McpToolParam(description = "天数", required = false) Integer days) {
return new Forecast(city, days == null ? 3 : days, List.of("晴", "多云", "雨"));
}
public record Forecast(String city, int days, List<String> items) {}
}
yaml
spring:
ai:
mcp:
server:
protocol: STREAMABLE # 一定要显式写,见 14.0 第 2 条
name: weather-server
version: 1.0.0
type: SYNC
capabilities:
resource: false
prompt: false
completion: false
启动日志里应该能看到三行 Registered tools: 2(McpServerAutoConfiguration:143)。看不到就说明扫描链断了。
14.4 @McpTool → SyncToolSpecification:逐行拆解
这一段是本章最核心的源码。SyncMcpToolProvider.getToolSpecifications()(SyncMcpToolProvider:66-159)把"一个 Java 方法"翻译成"一个 MCP 工具声明 + 一个调用回调",中间的全部决策都在这 90 行里。
14.4.1 入口的三层过滤与排序
java
List<SyncToolSpecification> toolSpecs = this.toolObjects.stream()
.map(toolObject -> Stream.of(this.doGetClassMethods(toolObject))
.filter(method -> method.isAnnotationPresent(McpTool.class)) // ①
.filter(McpPredicates.filterReactiveReturnTypeMethod()) // ②
.sorted(Comparator.comparing(Method::getName)) // ③
.map(mcpToolMethod -> { ... })
.toList())
.flatMap(List::stream)
.toList();
① isAnnotationPresent ------ 前面说过,不查继承链,不查接口。
② filterReactiveReturnTypeMethod ------ 名字起得容易误读,看实现(McpPredicates:68-79):
java
public static Predicate<Method> filterReactiveReturnTypeMethod() {
return method -> {
if (isNotReactiveReturnType.test(method)) { return true; }
logger.warn("SYNC Providers don't support reactive return types. Skipping method "
+ method + " with reactive return type " + method.getReturnType());
return false;
};
}
语义是"只保留非响应式返回类型的方法 ",即 SYNC provider 排除 Mono / Flux / Publisher 返回值的方法。与之对称,ASYNC provider 用 filterNonReactiveReturnTypeMethod()(AsyncMcpToolProvider:73),语义是"只保留响应式",排除所有阻塞返回类型。
这是一个非常容易踩的坑 :把
spring.ai.mcp.server.type从SYNC改成ASYNC,你的所有public String foo()形式的工具会被静默丢弃 ,只在 WARN 日志里留一行。反之亦然。SYNC ⟷ ASYNC 不是"执行线程"的切换,而是方法签名契约的切换。
STATELESS 分支还多加一层(:75):
java
.filter(McpPredicates.filterMethodWithBidirectionalParameters())
它会排除任何参数类型属于 {McpSyncRequestContext, McpAsyncRequestContext, McpSyncServerExchange, McpAsyncServerExchange} 的方法(McpPredicates:81-107)。理由很硬:无状态服务端没有会话,也就没有 Exchange 可注入。注意它只看参数类型 ,不看方法体------所以一个签名里带 McpSyncServerExchange 但根本不用它的方法,同样会被丢掉。
③ sorted(Comparator.comparing(Method::getName)) ------ 按方法名排序,目的是让工具注册顺序稳定 (否则 Class.getDeclaredMethods() 的顺序在不同 JVM/编译产物下可能变化,导致 tools/list 返回顺序抖动)。但注意这个排序是每个 Bean 内部 排序,Bean 之间的顺序仍取决于 ServerMcpAnnotatedBeans 的注册顺序(即 Bean 的初始化顺序)。如果你在意 tools/list 的稳定顺序,不要依赖它。
14.4.2 名字、描述与 title
java
String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name()
: mcpToolMethod.getName();
String toolDescription = toolJavaAnnotation.description();
(SyncMcpToolProvider:77-80)
- 工具名 :注解
name()为空时直接用 Java 方法名 。这里没有任何格式化、没有长度截断、没有非法字符过滤 。对照第 13 章客户端侧的McpToolUtils.format()(会删掉所有非[汉字a-zA-Z0-9_-]字符)与 64 字符截断------服务端侧这两件事都不做 。- 后果一:
@McpTool的方法名叫get.weather或get weather,MCP 协议里的工具名就是原样的get.weather/get weather。规范允许的字符集是[a-zA-Z0-9_-],越界名字会被严格客户端拒绝,而 Spring AI 不校验。 - 后果二:Java 允许 Unicode 标识符,中文方法名在 Java 里合法,于是工具名也会是中文。这在第 13 章讲客户端的
format()时提到过它"Support Han script + CJK blocks"------那是客户端加前缀时的处理,服务端不保证。 - 后果三:没有 64 字符限制。方法名可以很长。
- 后果一:
- 描述 :直接用
description(),没有回退到方法名 。也就是说@McpTool不写description,工具的描述就是空字符串 。这一点和 Spring AI 的@Tool不同------第 9 章讲过@Tool(description = "...")为空时行为有别。对 MCP 而言,空描述意味着模型只能靠名字猜语义,实际效果会明显变差。务必写 description。
title 有三级回退(:90、:106-116):
java
var title = toolJavaAnnotation.title(); // 1) @McpTool(title=...)
if (toolJavaAnnotation.annotations() != null) {
...
if (!Utils.hasText(title)) { title = toolAnnotations.title(); } // 2) @McpTool.McpAnnotations(title=...)
}
if (!Utils.hasText(title)) { title = toolName; } // 3) 回退到工具名
toolBuilder.title(title);
第 2 级这里有个逻辑怪味道 :if (toolJavaAnnotation.annotations() != null) 判断一个注解属性是否为 null。但 @McpTool 的 annotations() 有默认值 @McpAnnotations(McpTool:52),所以它永远不为 null ------这个判断恒真。也就是说第 2 级回退总是生效 :只要你在 @McpTool(annotations = @McpAnnotations(title = "X")) 里写了 title,它会覆盖 @McpTool(title = "...")?不,顺序反了------只有 toolJavaAnnotation.title() 为空时才会去看 annotations().title()(:106-108)。所以实际优先级是 @McpTool.title > @McpAnnotations.title > toolName,与 MCP 规范"ToolAnnotations.title 优先于 name"的措辞一致。但那个 != null 判断是纯粹的死代码。
14.4.3 五个 hint 与 _meta
java
toolBuilder.annotations(McpSchema.ToolAnnotations.builder()
.title(toolAnnotations.title())
.readOnlyHint(toolAnnotations.readOnlyHint()) // 默认 false
.destructiveHint(toolAnnotations.destructiveHint()) // 默认 true ← 注意
.idempotentHint(toolAnnotations.idempotentHint()) // 默认 false
.openWorldHint(toolAnnotations.openWorldHint()) // 默认 true ← 注意
.build());
(SyncMcpToolProvider:95-101,默认值见 McpTool.java:96-119)
四个默认值里有两个是"危险侧"默认:
| hint | 默认值 | 语义 | 默认取值意味着什么 |
|---|---|---|---|
readOnlyHint |
false |
工具是否不修改环境 | 默认"会改环境" |
destructiveHint |
true |
是否可能做破坏性更新 | 默认"可能破坏" |
idempotentHint |
false |
重复调用是否无额外副作用 | 默认"不幂等" |
openWorldHint |
true |
是否与开放世界交互 | 默认"开放世界" |
destructiveHint = true 与 openWorldHint = true 是 MCP 规范里两个"保守"默认值(规范原文说这些只是 hint,客户端不应据此做安全决策,McpTool.java:76-83 的注释也照抄了这句话)。但从产品角度看:如果你写的是一个只读查询工具,一定要显式写 @McpTool(annotations = @McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false)),否则某些严格客户端会把它标成"危险操作"并要求用户二次确认。
_meta 走 MetaUtils.getMeta(toolJavaAnnotation.metaProvider())(:84):
java
public static Map<String, Object> getMeta(Class<? extends MetaProvider> metaProviderClass) {
if (metaProviderClass == null) { return null; }
...
Constructor<? extends MetaProvider> constructor = getConstructor(metaProviderClass);
metaProvider = constructor.newInstance();
...
return metaProvider.getMeta() == null ? null : Collections.unmodifiableMap(metaProvider.getMeta());
}
默认是 DefaultMetaProvider.class(McpTool:72),返回 null → toolBuilder.meta(null) → 工具声明里不带 _meta。自定义的 MetaProvider 必须有无参构造 ,且是每次生成 spec 时 new 一个 (不是 Bean,不能注入依赖)。这意味着 _meta 只能是静态内容或与外部环境无关的动态内容。
14.4.4 outputSchema 与 generateOutputSchema
这是 2.x 服务端最有价值的新能力,也是默认关闭的:
java
Class<?> methodReturnType = mcpToolMethod.getReturnType();
if (toolJavaAnnotation.generateOutputSchema() && methodReturnType != null
&& methodReturnType != CallToolResult.class && methodReturnType != Void.class
&& methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType)
&& !ClassUtils.isSimpleValueType(methodReturnType)) {
toolBuilder.outputSchema(this.getJsonMapper(),
McpJsonSchemaGenerator.generateFromType(mcpToolMethod.getGenericReturnType()));
}
(SyncMcpToolProvider:123-131)
把这段判定拆开(一个 null 检查 + 五个实质性条件):
| 条件 | 不满足时的结果 |
|---|---|
generateOutputSchema() == true |
默认 false (McpTool:58)→ 绝大多数人默认拿不到 outputSchema |
返回类型不是 CallToolResult |
你自己已经能完全控制结果了,不需要框架代劳 |
返回类型不是 Void / void |
无返回值无 Schema 可言 |
| 不是 primitive 或包装类型 | int / Integer 等走 TEXT 更合适 |
| 不是 simple value type | String、Number、Date、URI、Enum...(ClassUtils.isSimpleValueType) |
ClassUtils.isSimpleValueType 的覆盖范围比多数人想的大(CharSequence、Number、Date、Temporal、URI、URL、Locale、Class、Enum...)。所以只有"自定义 DTO / 集合 / Map"才会真正生成 outputSchema。
ASYNC 版本的条件不一样(AsyncMcpToolProvider:125-138):
java
if (toolJavaAnnotation.generateOutputSchema()
&& !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod)
&& !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) {
ReactiveUtils.getReactiveReturnTypeArgument(mcpToolMethod).ifPresent(typeArgument -> {
Class<?> methodReturnType = typeArgument instanceof Class<?> ? (Class<?>) typeArgument : null;
if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType)
&& !ClassUtils.isSimpleValueType(methodReturnType)) {
toolBuilder.outputSchema(this.getJsonMapper(),
McpJsonSchemaGenerator.generateFromClass((Class<?>) typeArgument));
}
});
}
差异有三处值得记:
-
它不解包到原始返回类型,而是取泛型实参 ------
Mono<Forecast>→ 取Forecast。 -
用的是
generateFromClass而不是generateFromType。看McpJsonSchemaGenerator:javapublic static String generateFromClass(Class<?> clazz) { ... typeSchemaCache.computeIfAbsent(clazz, ...); } public static String generateFromType(Type type) { ... typeSchemaCache.computeIfAbsent(type, ...); }两者最终都调用
internalGenerateFromType,且共用同一个typeSchemaCache(:76-78、:222、:233)。generateFromClass会把Class<?>当成Type塞进同一个 Map------因为Class实现了Type,这没问题,但意味着两个方法的缓存是混在一起的。 -
它对
Void的判定依赖泛型实参 :Mono<Void>能被识别(ReactiveUtils.isReactiveReturnTypeOfVoid),但裸Mono(无泛型实参)会走进isReactiveReturnTypeOfVoid == false且getReactiveReturnTypeArgument返回Optional.empty()→ 不生成 outputSchema,也不报错。
还有一个隐蔽点:typeArgument instanceof Class<?> ? (Class<?>) typeArgument : null ------ 如果泛型实参是参数化类型 (比如 Mono<List<Forecast>>),typeArgument 是 ParameterizedType 不是 Class,于是 methodReturnType = null,接着 ClassUtils.isPrimitiveOrWrapper(null) 与 isSimpleValueType(null) 都返回 false,于是会走到 generateFromClass((Class<?>) typeArgument) ------ 把 ParameterizedType 强转成 Class 会抛 ClassCastException。
也就是说:ASYNC 模式下写
@McpTool(generateOutputSchema = true) Mono<List<Forecast>> foo()会在启动生成 spec 时 抛ClassCastException。SYNC 模式写List<Forecast> foo()反而没问题(走generateFromType,支持泛型)。这是 SYNC 与 ASYNC 实现不对称留下的一个真实缺口。
14.4.5 ReturnMode 的三态决策
java
boolean useStructuredOtput = tool.outputSchema() != null;
ReturnMode returnMode = useStructuredOtput ? ReturnMode.STRUCTURED
: (methodReturnType == Void.TYPE || methodReturnType == void.class ? ReturnMode.VOID
: ReturnMode.TEXT);
(SyncMcpToolProvider:135-139;变量名 useStructuredOtput 是源码里的拼写错误)
判定顺序是先 outputSchema、后 void:
csharp
outputSchema != null ──是──> STRUCTURED
│ 否
▼
returnType == void/Void ──是──> VOID
│ 否
▼
TEXT
ASYNC 版(:141-143):
java
ReturnMode returnMode = tool.outputSchema() != null ? ReturnMode.STRUCTURED
: ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod) ? ReturnMode.VOID
: ReturnMode.TEXT;
ReturnMode 是个三值枚举(ReturnMode.java:19-23),没有默认值概念,由 provider 决定。
注意一个反直觉组合:void 方法 + generateOutputSchema = true 时,outputSchema 生成不了 (前面六个条件里有 != Void.class),所以走 VOID------正确。但**String 方法 + generateOutputSchema = true** 也生成不了(isSimpleValueType),所以走 TEXT------也正确。只有 DTO 才能拿到 STRUCTURED,设计是自洽的。
14.4.6 回调对象的构造
java
BiFunction<McpSyncServerExchange, CallToolRequest, CallToolResult> methodCallback =
new SyncMcpToolMethodCallback(returnMode, mcpToolMethod, toolObject);
var toolSpec = SyncToolSpecification.builder().tool(tool).callHandler(methodCallback).build();
(:141-144)
STATELESS 版换成了 SyncStatelessMcpToolMethodCallback,且函数式的第一个参数是 McpTransportContext 而不是 McpSyncServerExchange(SyncStatelessMcpToolProvider:145-146)------这正好解释了 14.4.1 里那层"双向参数过滤":无状态回调根本拿不到 Exchange。
AbstractMcpToolProvider 里还有两个 protected 钩子(:45-51):
java
protected Method[] doGetClassMethods(Object bean) { return bean.getClass().getDeclaredMethods(); }
protected McpTool doGetMcpToolAnnotation(Method method) { return method.getAnnotation(McpTool.class); }
基类默认用 getDeclaredMethods()(没有 去重/过滤合成方法),SpringAiSyncToolProvider 覆写成了 AnnotationProviderUtil.beanMethods(bean)(SyncMcpAnnotationProviders:188-191)。如果你直接 new 一个 SyncMcpToolProvider (不走 Spring),拿到的是基类实现------桥接方法可能导致同一个工具被注册两次。这也是为什么官方路径一定要走 SyncMcpAnnotationProviders。
14.5 输入 Schema 的生成:McpJsonSchemaGenerator
这一节解决一个高频问题:"我明明写了参数,为什么模型传进来的值是 null?"
14.5.1 生成器的配置
java
private static final boolean PROPERTY_REQUIRED_BY_DEFAULT = true;
static {
Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
Module openApiModule = new Swagger2Module();
Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new McpSpringAiSchemaModule()
: new McpSpringAiSchemaModule(McpSpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
SchemaGeneratorConfigBuilder subtypeConfigBuilder = new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
.with(jacksonModule).with(openApiModule).with(springAiSchemaModule)
.with(Option.EXTRA_OPEN_API_FORMAT_VALUES)
.with(Option.STANDARD_FORMATS)
.with(Option.PLAIN_DEFINITION_KEYS)
.without(Option.SCHEMA_VERSION_INDICATOR);
if (KotlinDetector.isKotlinReflectPresent()) {
subtypeConfigBuilder.with(new KotlinModule());
}
SUBTYPE_SCHEMA_GENERATOR = new SchemaGenerator(subtypeConfigBuilder.build());
}
(McpJsonSchemaGenerator:65-108)
三层 Module 叠在一起:JacksonSchemaModule(识别 @JsonProperty 等)+ Swagger2Module(识别 @Schema)+ McpSpringAiSchemaModule(识别 @McpToolParam)。PROPERTY_REQUIRED_BY_DEFAULT = true 是硬编码的常量,没有配置开关------默认所有参数都是必填。
和客户端/第 9 章讲的 Spring AI JsonSchemaGenerator 相比,这里的差异在于 without(Option.SCHEMA_VERSION_INDICATOR):生成的子 Schema 里不带 $schema 字段 ,因为外层会统一加(:147)。
14.5.2 主流程
java
private static String internalGenerateFromMethodArguments(Method method) {
// ① 有 CallToolRequest 参数且没有其他"业务参数" → 最小 Schema
boolean hasCallToolRequestParam = Arrays.stream(method.getParameterTypes())
.anyMatch(type -> CallToolRequest.class.isAssignableFrom(type));
if (hasCallToolRequestParam) {
boolean hasOtherParams = ...;
if (!hasOtherParams) {
ObjectNode schema = JacksonUtils.getDefaultJsonMapper().createObjectNode();
schema.put("type", "object");
schema.putObject("properties");
schema.putArray("required");
return schema.toPrettyString();
}
}
// ② 正常路径
ObjectNode schema = JacksonUtils.getDefaultJsonMapper().createObjectNode();
schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
schema.put("type", "object");
ObjectNode defs = schema.putObject("$defs");
ObjectNode properties = schema.putObject("properties");
List<String> required = new ArrayList<>();
for (int i = 0; i < method.getParameterCount(); i++) {
Parameter parameter = method.getParameters()[i];
String parameterName = parameter.getName(); // ← -parameters!
Type parameterType = method.getGenericParameterTypes()[i];
if (parameter.isAnnotationPresent(McpProgressToken.class)) { continue; }
if (parameterType instanceof Class<?> pc && McpMeta.class.isAssignableFrom(pc)) { continue; }
if (parameterType instanceof Class<?> pc && (...六种基础设施类型...)) { continue; }
if (KotlinDetector.isSuspendingFunction(method) && i == method.getParameterCount() - 1) { continue; }
if (isMethodParameterRequired(method, i)) { required.add(parameterName); }
ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
JsonSchemaUtils.hoistDefsToRoot(schema, parameterNode);
String parameterDescription = getMethodParameterDescription(method, i);
if (Utils.hasText(parameterDescription)) {
parameterNode.put("description", parameterDescription);
}
properties.set(parameterName, parameterNode);
}
if (defs.isEmpty()) { schema.remove("$defs"); }
var requiredArray = schema.putArray("required");
required.forEach(requiredArray::add);
return schema.toPrettyString();
}
(McpJsonSchemaGenerator:118-212)
被跳过的参数一共七类(:160-185):
| 跳过条件 | 说明 |
|---|---|
@McpProgressToken 标注 |
进度令牌,从请求元数据取,不是模型填的 |
类型是 McpMeta |
请求 _meta,同上 |
McpSyncRequestContext / McpAsyncRequestContext |
双向上下文 |
McpSyncServerExchange / McpAsyncServerExchange |
会话对象 |
McpTransportContext |
传输上下文 |
CallToolRequest |
原始请求 |
| Kotlin suspend 的最后一个参数 | 编译器合成的 Continuation |
注意跳过的判断用的是 parameterType instanceof Class<?> parameterClass(:165、:170)------只对非泛型 参数生效。参数类型若是 Mono<String> 之类(泛型),两个 if 都会走 else,不会被跳过;好在工具方法本来也不会这么写。
14.5.3 $defs 提升:一个必须存在的补丁
java
ObjectNode parameterNode = SUBTYPE_SCHEMA_GENERATOR.generateSchema(parameterType);
// victools generates self-contained schemas where $defs and the $ref
// pointers into them are rooted at the sub-schema. Inlining the
// sub-schema under properties.<paramName> re-parents existing
// "#/$defs/<Name>" refs to the outer root, leaving them unresolvable.
// Hoist $defs to the outer root so those refs resolve again.
JsonSchemaUtils.hoistDefsToRoot(schema, parameterNode);
(:190-196)
这段注释解释了一个真实的 bug 修复:victools 为单个参数生成的 Schema 是自包含的 (自带 $defs 和指向它的 $ref: "#/$defs/X")。当你把它塞进 properties.<name> 后,那个 #/$defs/X 就变成指向外层根 了,而外层根此时还没有 $defs → 引用悬空。hoistDefsToRoot 把子 Schema 的 $defs 搬到外层根,引用才重新可解析。
如果外层没有 $defs(defs.isEmpty()),第 204-206 行会把它删掉,避免产生一个空的 "$defs": {}。
这条也解释了为什么嵌套 DTO(DTO 里套 DTO)在 MCP 服务端是能正常工作的 ------
$ref会被正确提升。如果你自己在别处手写 Schema 拼接,别忘了这一步。
14.5.4 required 的四级回退
java
private static boolean isMethodParameterRequired(Method method, int index) {
Parameter parameter = method.getParameters()[index];
var toolParamAnnotation = parameter.getAnnotation(McpToolParam.class);
if (toolParamAnnotation != null) { return toolParamAnnotation.required(); } // ①
var propertyAnnotation = parameter.getAnnotation(JsonProperty.class);
if (propertyAnnotation != null) { return propertyAnnotation.required(); } // ②
var schemaAnnotation = parameter.getAnnotation(Schema.class);
if (schemaAnnotation != null) {
return schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED
|| schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO
|| schemaAnnotation.required(); // ③
}
if (Nullness.forParameter(parameter) == Nullness.NULLABLE) { return false; } // ④
return PROPERTY_REQUIRED_BY_DEFAULT; // ⑤ true
}
(:250-274)
优先级:@McpToolParam.required > @JsonProperty.required > @Schema.requiredMode > 参数可空性(Nullness.forParameter,识别 @Nullable)> 默认 true。
description 是三级回退(:276-295):@McpToolParam.description > @JsonPropertyDescription.value > @Schema.description > null(不写 description)。
14.5.5 @McpArg 去哪了?
这是本章最需要澄清的一件事。第 13 章提到过 @McpArg(McpArg.java:30-50),它有三个属性 name() / description() / required()(默认 false)。
在服务端工具 Schema 生成路径上,@McpArg 完全不参与。 核对方式很简单:
McpJsonSchemaGenerator只 import 了McpToolParam(:51),没有McpArg;McpSpringAiSchemaModule只处理McpToolParam(:45-57);isMethodParameterRequired/getMethodParameterDescription里也没有McpArg。
全仓库对 McpArg 的生产代码 引用只有两处,而且都在 Prompt 路径上,与 Tool 无关:
AbstractMcpPromptMethodCallback:266------McpArg arg = param.getAnnotation(McpArg.class);(取 prompt 参数的 name/description/required)PromptAdapter:114-115------ 同样的用途
其余命中全部是 src/test 下的 Prompt 测试与示例。也就是说:@McpArg 是 @McpPrompt 的配套注解,@McpTool 的配套注解是 @McpToolParam。 所以:
写服务端工具请用
@McpToolParam,不要用@McpArg。 用@McpArg(required = false, description = "...")修饰的参数,在生成的 Schema 里会是"必填且无描述",与你的意图完全相反。
反过来,@McpToolParam 的默认 required = true(McpToolParam.java:36),与 PROPERTY_REQUIRED_BY_DEFAULT 一致。想声明可选参数必须显式写 @McpToolParam(required = false) ,或者用 @Nullable 标注参数。
14.5.6 -parameters:一条会静默失效的硬依赖
回到开头那个问题------"为什么模型传进来的值是 null"。绑定逻辑在这里:
java
Object rawArgument = toolInputArguments.get(parameter.getName());
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
(AbstractMcpToolMethodCallback:139-140)
而 Schema 的属性名也来自 parameter.getName()(McpJsonSchemaGenerator:156)。
两者同源,所以理论上是一致的------前提是 parameter.getName() 返回真实名字 。Java 反射只有在编译时带 -parameters(javac)才会把真实参数名写进 class 文件的 MethodParameters 属性;否则 getName() 返回 arg0、arg1......
此时会发生什么?
- Schema 的属性名变成
arg0、arg1,模型看到的是{"arg0": "string"}------它当然不知道该填什么,可能乱填也可能不填; toolInputArguments.get("arg0")拿到模型实际传的键(模型按 Schema 填的也是arg0)......如果模型恰好照着 Schema 填了arg0,绑定居然是成功的。
所以严格说:没有 -parameters 时,Schema 与绑定是自洽的 ,但对模型完全不可读 ------这才是真正的灾难:工具能跑,但模型永远用不对。而且没有任何报错或警告。
Spring Boot 的 spring-boot-starter-parent 默认给 maven-compiler-plugin 加了 -parameters(Spring AI 自己的 pom.xml:427-431 也显式加了,还专门写了注释说明"execution 级别的参数列表会覆盖全局,必须重复写",pom.xml:474-475)。所以:
- 用 Spring Boot 官方 parent 构建的应用:没问题。
- 自己手写
maven-compiler-plugin配置、覆盖了<compilerArgs>的项目:会中招。 - Gradle 项目:需要
options.compilerArgs += ["-parameters"](Spring Boot Gradle 插件会自动加)。 - 把 MCP 服务端打成普通 jar 给别人依赖的场景:取决于那个 jar 的编译参数。
自检方法:
bash
javap -p -l -c yourclass.class | grep -A2 MethodParameters
# 或者更直接:
javap -v YourService.class | grep -A5 "MethodParameters"
14.5.7 缓存:两个 ConcurrentReferenceHashMap
java
private static final Map<Method, String> methodSchemaCache = new ConcurrentReferenceHashMap<>(256);
private static final Map<Type, String> typeSchemaCache = new ConcurrentReferenceHashMap<>(256);
(:76、:78)
ConcurrentReferenceHashMap 默认是软引用,内存紧张时可被回收------作为缓存容器是合理的。两个点:
- 缓存 key 是
Method对象。Method的equals/hashCode比较的是 declaringClass + name + 参数类型,所以同一方法重复获取Method实例也能命中。 generateForMethodInput缓存的是最终 JSON 字符串 ,也就是 Schema 一旦生成就不变了。运行时无法动态改变工具签名(MCP 的tools/list变更通知只支持"整个工具集的增减",不支持"改 Schema")。
14.6 ReturnMode 与结果转换:从方法返回值到 CallToolResult
ReturnMode 只是个枚举,真正干活的是 AbstractMcpToolMethodCallback.convertValueToCallToolResult(:171-202)。
14.6.1 SYNC 路径的转换表
java
protected CallToolResult convertValueToCallToolResult(Object result) {
// ① 已经是 CallToolResult → 直接返回
if (result instanceof CallToolResult) { return (CallToolResult) result; }
Type returnType = this.toolMethod.getGenericReturnType();
// ② VOID
if (this.returnMode == ReturnMode.VOID || returnType == Void.TYPE || returnType == void.class) {
return CallToolResult.builder().addTextContent(jsonHelper.toJson("Done")).build();
}
// ③ STRUCTURED
if (this.returnMode == ReturnMode.STRUCTURED) {
String jsonOutput = jsonHelper.toJson(result);
Object structuredOutput = jsonHelper.fromJson(jsonOutput, Object.class);
return CallToolResult.builder().structuredContent(structuredOutput).build();
}
// ④ TEXT:null
if (result == null) { return CallToolResult.builder().addTextContent("null").build(); }
// ⑤ TEXT:String 直通,不再 JSON 编码
if (result instanceof String) { return CallToolResult.builder().addTextContent((String) result).build(); }
// ⑥ TEXT:其他类型 → JSON
return CallToolResult.builder().addTextContent(jsonHelper.toJson(result)).build();
}
逐条看:
① CallToolResult 直通。 方法自己返回 CallToolResult 时框架完全不干预------这是完全控制 逃生舱:想返回图片、多段内容、带 _meta、带 isError 都可以。注意此时 returnMode 是 TEXT(因为 generateOutputSchema 条件里排除了 CallToolResult),但根本走不到 ④-⑥。
② VOID 返回的是 "Done"(带引号)。 因为用的是 jsonHelper.toJson("Done")------JSON 序列化一个字符串会带双引号。模型看到的内容是 "Done" 而不是 Done。这是个无害但有点丑的细节;如果你在意,自己返回 CallToolResult.builder().addTextContent("Done").build()。
③ STRUCTURED 有一次 JSON 往返。 toJson(result) 再 fromJson(json, Object.class)------先把对象序列化成字符串,再反序列化成 Object(实际是 Map/List)。为什么不直接用 result?因为 structuredContent 需要的是一个已经被 JSON 化的通用结构 ,而不是原始 POJO(否则 Jackson 序列化 CallToolResult 时可能会带上 POJO 的类型信息或不可序列化的字段)。代价是一次完整的序列化 + 反序列化,对大对象有开销。
另外注意:STRUCTURED 分支只设置 structuredContent,不设置 content 。MCP 规范里 structuredContent 与 content 是"二选一但建议都给"的关系(规范允许只给前者),但部分客户端只解析 content ------如果你遇到"工具明明返回了结构化数据,客户端却收不到"的情况,多半是客户端的问题。想兼容就自己返回 CallToolResult,两个字段都填。
④ null 返回字符串 "null"。 不是空内容,是四字符的 null。
⑤ String 直通。 这是最符合直觉的一条:返回 "晴,26℃" 时模型收到的就是 晴,26℃,不会再套一层引号 (对比 ② 的 "Done")。所以 TEXT 模式下"返回 String"和"返回别的类型"的序列化行为是不一致的 ------String 不经过 Jackson。
14.6.2 ASYNC 路径:convertToCallToolResult
java
protected Mono<CallToolResult> convertToCallToolResult(Object result) {
if (result instanceof Mono) {
Mono<?> monoResult = (Mono<?>) result;
if (ReactiveUtils.isReactiveReturnTypeOfCallToolResult(this.toolMethod)) {
return ((Mono<CallToolResult>) monoResult).onErrorResume(this::toErrorResultOrPropagate);
}
if (ReactiveUtils.isReactiveReturnTypeOfVoid(this.toolMethod)) {
return monoResult.then(Mono.just(CallToolResult.builder()
.addTextContent(jsonHelper.toJson("Done")).build()))
.onErrorResume(this::toErrorResultOrPropagate);
}
return monoResult.map(this::mapValueToCallToolResult).onErrorResume(this::toErrorResultOrPropagate);
}
if (result instanceof Flux) {
Flux<?> fluxResult = (Flux<?>) result;
if (ReactiveUtils.isReactiveReturnTypeOfCallToolResult(this.toolMethod)) {
return ((Flux<CallToolResult>) fluxResult).next()...;
}
if (ReactiveUtils.isReactiveReturnTypeOfVoid(this.toolMethod)) {
return fluxResult.then(Mono.just(... "Done" ...))...;
}
return fluxResult.next().map(this::mapValueToCallToolResult)...; // ← 只取第一个!
}
if (result instanceof Publisher) { ...同上... }
throw new IllegalStateException("Expected reactive return type but got: " + ...);
}
(AbstractAsyncMcpToolMethodCallback:80-146)
最重要的一条:Flux 会被 .next() 截断成第一个元素。
java
return fluxResult.next().map(this::mapValueToCallToolResult).onErrorResume(...);
也就是说 @McpTool Mono<...> 是"异步单值",而 @McpTool Flux<Foo> 不是 "流式返回多个结果"------它等于 Mono<Foo>,只取第一个元素,其余元素被取消订阅。
这是个很容易产生误解的地方。MCP 的
tools/call是一次请求一次响应 ,协议层面没有"工具流式返回"。想做长时间进度的工具,正确做法是用McpSyncServerExchange/McpAsyncRequestContext发 progress notification (AbstractMcpToolMethodCallback:111注入上下文),而不是返回Flux。如果你真的返回了
Flux并期望模型看到多个结果:它只会看到第一个,而且没有任何警告。
另一个细节:三个分支(Mono / Flux / Publisher)几乎是复制粘贴的三份,判据都来自 ReactiveUtils 的两个缓存化判定。Publisher 分支用 Mono.from(publisherResult) 统一处理------所以自定义 Publisher 实现也只取第一个元素。
最后那句 throw new IllegalStateException("Expected reactive return type but got: ...")(:144-145)理论上不可达:ASYNC provider 的 filterNonReactiveReturnTypeMethod 已经保证方法返回类型是响应式的。除非你手工构造 AsyncMcpToolMethodCallback 并传了一个返回 String 的方法------那时这个异常会在调用时才抛出来。
14.6.3 参数绑定:buildMethodArguments
java
protected Object[] buildMethodArguments(T exchangeOrContext, Map<String, Object> toolInputArguments,
CallToolRequest request) {
return Stream.of(this.toolMethod.getParameters()).map(parameter -> {
if (McpSyncRequestContext.class.isAssignableFrom(parameter.getType())
|| McpAsyncRequestContext.class.isAssignableFrom(parameter.getType())) {
return this.createRequestContext(exchangeOrContext, request);
}
if (parameter.isAnnotationPresent(McpProgressToken.class)) {
return request != null ? request.progressToken() : null;
}
if (McpMeta.class.isAssignableFrom(parameter.getType())) {
return request != null ? new McpMeta(request.meta()) : new McpMeta(null);
}
if (CallToolRequest.class.isAssignableFrom(parameter.getType())) { return request; }
if (McpTransportContext.class.isAssignableFrom(parameter.getType())) {
return this.resolveTransportContext(exchangeOrContext);
}
if (isExchangeOrContextType(parameter.getType())) { return exchangeOrContext; }
Object rawArgument = toolInputArguments.get(parameter.getName());
return buildTypedArgument(rawArgument, parameter.getParameterizedType());
}).toArray();
}
(AbstractMcpToolMethodCallback:103-142)
注入优先级(从上到下):
McpSyncRequestContext/McpAsyncRequestContext→ 构造DefaultMcpSyncRequestContext.builder().request(request).exchange(exchange).build()(SyncMcpToolMethodCallback:69-71)@McpProgressToken标注的参数 →request.progressToken()McpMeta→new McpMeta(request.meta())CallToolRequest→ 原始请求McpTransportContext→exchange.transportContext()McpSyncServerExchange/McpAsyncServerExchange(由子类isExchangeOrContextType判定)→ exchange- 兜底 :按参数名从
request.arguments()里取
注意第 1 条与第 6 条有重叠 :McpSyncRequestContext 既在第 1 条被拦截,也在 SyncMcpToolMethodCallback.isExchangeOrContextType(:62-66)里被列为 true。因为第 1 条在前,所以拿到的是包装后的上下文对象 而不是 exchange------这是设计意图(上下文对象里同时有 request 和 exchange)。
类型转换:
java
protected Object buildTypedArgument(@Nullable Object value, Type type) {
if (value == null) { return null; }
if (type instanceof Class<?>) { return jsonHelper.convertToTypedObject(value, (Class<?>) type); }
String json = jsonHelper.toJson(value, true);
return jsonHelper.fromJson(json, type);
}
(:150-162)
- 非泛型类型 :
convertToTypedObject(Jackson 的convertValue语义)。 - 泛型类型 (如
List<Foo>):先toJson(value, true)再fromJson(json, type)------绕一圈 JSON。因为 Jackson 的convertValue对复杂泛型有时拿不到完整TypeReference,这里用字符串做中介最稳。代价同样是一次往返。
value == null 直接返回 null。 也就是说:可选参数没传时,包装类型得到 null,基本类型(int / long / boolean)会得到 null 然后在反射调用时抛 IllegalArgumentException: argument type mismatch。
这是本文第 4 条总纲的延伸:不要给
@McpTool方法用基本类型声明"可选"参数 。要么用Integer/Boolean,要么把它设成必填。
14.6.4 callMethod 与异常展开
java
protected Object callMethod(Object[] methodArguments) {
this.toolMethod.setAccessible(true);
try {
return this.toolMethod.invoke(this.toolObject, methodArguments);
}
catch (IllegalAccessException ex) {
throw new RuntimeException("Failed to access tool method", ex);
}
catch (InvocationTargetException ex) {
Throwable cause = ex.getCause();
if (cause instanceof RuntimeException re) { throw re; }
if (cause instanceof Error err) { throw err; }
throw new UndeclaredThrowableException(cause);
}
}
(:74-92)
InvocationTargetException 的三分支展开是整套异常语义的源头:
| 方法抛出的东西 | 展开后 |
|---|---|
RuntimeException(含 McpError) |
原样抛(rethrow) |
Error |
原样抛 |
| 受检异常 | 包成 UndeclaredThrowableException |
| 反射访问失败 | 包成 RuntimeException("Failed to access tool method") |
UndeclaredThrowableException 是关键:受检异常被伪装成"未声明的可抛出物" ,后面 14.7 会看到它决定了"是硬失败还是转成 isError 结果"。
14.7 服务端异常语义:三类 Throwable 的三种归宿
第 13 章 13.9.4 讲客户端时说过:McpError 硬抛、isError=true 转异常、结果是 Content[]。服务端这边的规则要更细一点,且与 Spring AI @Tool 的异常契约是刻意对齐 的(源码注释里反复出现 "follows the @Tool contract")。
14.7.1 SYNC:SyncMcpToolMethodCallback.apply
java
@Override
public CallToolResult apply(McpSyncServerExchange exchange, CallToolRequest request) {
validateSyncRequest(request);
try {
Object[] args = this.buildMethodArguments(exchange, request.arguments(), request);
Object result = this.callMethod(args);
return this.processResult(result);
}
catch (UndeclaredThrowableException e) {
throw e; // ① 受检异常 → 硬抛
}
// McpError extends RuntimeException --- this catch must precede the plain
// RuntimeException catch below, or McpError would be silently swallowed into
// an error CallToolResult instead of propagating as a hard failure.
catch (McpError e) {
throw e; // ② 协议级错误 → 硬抛
}
catch (RuntimeException e) {
return this.createSyncErrorResult(e); // ③ 运行时异常 → isError=true
}
}
(SyncMcpToolMethodCallback:87-117)
源码里那段注释(:105-107)值得单独引用:它明确说明 catch (McpError) 必须排在 catch (RuntimeException) 之前 ,否则 McpError(继承自 RuntimeException)会被静默吞掉,变成一句 isError=true 的结果,而不是冒泡到协议层的硬失败。这是写这段代码的工程师留下的"别动顺序"警告。
三种归宿:
| 抛出物 | 归宿 | 谁看到什么 |
|---|---|---|
McpError |
硬抛 (不返回 CallToolResult) |
由传输层/Server 转成 JSON-RPC 错误响应;工具调用失败 |
UndeclaredThrowableException(包着受检异常) |
硬抛 | 同上 |
其他 RuntimeException |
isError=true 的 CallToolResult |
工具调用成功返回 ,但结果标记为错误,模型能看到消息 |
Error(OutOfMemoryError 等) |
由 callMethod 原样抛出,不被这里捕获 |
JVM 级问题,直接冒 |
第 3 条是给模型看的,第 1/2 条是给运维看的。 这是整个设计的核心分野:业务异常(参数不合法、查不到数据)应该变成 isError 结果让模型看到并重试;协议异常(比如 URL elicitation 这种需要客户端配合的流程)必须冒泡。
14.7.2 错误消息的内容
java
protected CallToolResult createSyncErrorResult(Exception e) {
Throwable rootCause = findCauseUsingPlainJava(e);
return CallToolResult.builder()
.isError(true)
.addTextContent(e.getMessage() + System.lineSeparator() + rootCause.getMessage())
.build();
}
(AbstractSyncMcpToolMethodCallback:73-79)
消息是两段拼接 :异常自身的 getMessage() + 换行 + 最深层根因 的 getMessage()。
findCauseUsingPlainJava(AbstractMcpToolMethodCallback:222-229)的循环条件:
java
while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
rootCause = rootCause.getCause();
}
它防了自引用(getCause() == this),但没防循环引用(A→B→A)。理论上会死循环------实际上除非有人手工构造,很少见。
这里有一个必须警惕的信息泄漏问题 :e.getMessage() 与 rootCause.getMessage() 会被原样塞进返回给客户端(进而喂给模型)的文本里。如果你的异常消息里含 SQL、内部 URL、用户数据、堆栈片段......它们会直接出境。
生产建议:永远不要直接抛业务异常,包一层。
java@McpTool(name = "query_order", description = "查询订单") public String queryOrder(@McpToolParam(description = "订单号") String orderNo) { try { return orderRepository.find(orderNo).toString(); } catch (DataAccessException ex) { // 消息里可能有 SQL throw new IllegalStateException("订单查询失败,请稍后重试或联系客服"); // 干净消息 } }或者更彻底:让工具方法返回
CallToolResult,自己控制isError与文案。
注意 AbstractMcpToolMethodCallback.createErrorMessage(:209-211)还留了一个未被使用的方法:
java
protected String createErrorMessage(Throwable e) {
return "Error invoking method: %s".formatted(e.getMessage());
}
它在 SYNC 路径上没有被调用 (createSyncErrorResult 是自己拼的字符串),而 ASYNC 的 toErrorResultOrPropagate 里硬编码了同样的格式串 "Error invoking method: %s"(AbstractAsyncMcpToolMethodCallback:172)------也就是说,SYNC 与 ASYNC 的错误文案格式不一样:
- SYNC:
"<msg>\n<rootCauseMsg>" - ASYNC(响应式管道内出错):
"Error invoking method: <msg>"(没有根因) - ASYNC(方法体同步抛出
RuntimeException):走createAsyncErrorResult,与 SYNC 一致("msg\nrootCauseMsg")
三种格式并存,是历史演进留下的痕迹。
14.7.3 ASYNC:toErrorResultOrPropagate
java
private Mono<CallToolResult> toErrorResultOrPropagate(Throwable e) {
if (e instanceof RuntimeException
&& !(e instanceof UndeclaredThrowableException)
&& !(e instanceof McpError)) {
return Mono.just(CallToolResult.builder()
.isError(true)
.addTextContent("Error invoking method: %s".formatted(e.getMessage()))
.build());
}
return Mono.error(e);
}
(AbstractAsyncMcpToolMethodCallback:168-176)
这条判据与 SYNC 的三个 catch 语义等价但写法不同:
vbnet
RuntimeException 且 不是 UndeclaredThrowableException 且 不是 McpError → 转成 isError
其余(Error / 受检异常 / McpError) → Mono.error(硬失败)
注释(:158-167)把契约讲得很清楚:"ordinary RuntimeExceptions are conveyed to the model, while declared checked exceptions (carried as UndeclaredThrowableException), Errors and protocol-level McpErrors bubble up as hard failures."
14.7.4 与客户端的呼应
把第 13 章 13.9.4 与本节的规则接起来,得到一条完整链路:
ini
服务端方法抛 RuntimeException
→ createSyncErrorResult → CallToolResult{ isError=true, content=[TextContent(msg)] }
→ 协议传输
→ 客户端 SyncMcpToolCallback(13.9):isError=true → 转成异常抛给 Spring AI
→ DefaultToolCallingManager(第 11 章)→ 转成 ToolExecutionResult 回喂模型
关键点:服务端认为"我把错误告诉模型了",客户端认为"服务端报错了,我抛异常" 。这两件事在语义上是冲突的:服务端想让模型 看到消息并自我纠正,而客户端把它变成了一个异常 (是否会回喂模型取决于 ToolExecutionExceptionProcessor,第 10 章讲过默认行为是把消息作为工具结果回喂)。
所以最终效果通常是对的(模型看到了消息),但中间经历了一次"错误 → 结果 → 异常 → 结果"的往返。如果你在客户端自定义了异常处理,要意识到这一点。
另一端:
go
服务端抛 McpError
→ 硬抛,不产生 CallToolResult
→ 传输层产生 JSON-RPC error 响应(不是 result)
→ 客户端收到 McpError(13.9 的"硬抛"分支)
→ 冒泡到 Spring AI 调用方
这条链路不会把消息交给模型------它是协议级失败(例如 elicitation 流程需要客户端配合),模型无法"重试"解决。
14.8 第二条来源:ToolCallback → ToolSpecification
如果你的应用里已经有了一批用 Spring AI @Tool 写的工具(第 9、10 章的内容),现在想把它们同时暴露成 MCP 工具,不需要重写 ------ToolCallbackConverterAutoConfiguration 会帮你转。
14.8.1 转换的核心:toSharedSyncToolSpecification
java
private static SharedSyncToolSpecification toSharedSyncToolSpecification(ToolCallback toolCallback,
@Nullable MimeType mimeType) {
var tool = McpSchema.Tool
.builder(toolCallback.getToolDefinition().name(),
new JacksonMcpJsonMapper(JacksonUtils.getDefaultJsonMapper()),
toolCallback.getToolDefinition().inputSchema())
.description(toolCallback.getToolDefinition().description())
.build();
return new SharedSyncToolSpecification(tool, (exchangeOrContext, request) -> {
try {
String callResult = toolCallback.call(jsonHelper.toJson(request.arguments()),
new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchangeOrContext)));
if (mimeType != null && mimeType.toString().startsWith("image")) {
McpSchema.Annotations annotations = McpSchema.Annotations.builder()
.audience(List.of(Role.ASSISTANT)).build();
return McpSchema.CallToolResult.builder()
.content(List.of(McpSchema.ImageContent.builder(callResult, mimeType.toString())
.annotations(annotations).build()))
.isError(false).build();
}
return McpSchema.CallToolResult.builder()
.content(List.of(McpSchema.TextContent.builder(callResult).build()))
.isError(false).build();
}
catch (Exception e) {
return McpSchema.CallToolResult.builder()
.content(List.of(McpSchema.TextContent.builder(e.getMessage()).build()))
.isError(true).build();
}
});
}
(McpToolUtils:251-288)
与注解路径(14.4)相比,这条路径少了很多东西:
| 能力 | 注解路径(@McpTool) |
ToolCallback 路径 |
|---|---|---|
title |
三级回退有 | 无 (Tool builder 只设了 name/description) |
ToolAnnotations(四个 hint) |
有 | 无 |
_meta / MetaProvider |
有 | 无 |
outputSchema / generateOutputSchema |
有(默认关) | 无,且无法通过配置开启 |
inputSchema |
victools 现算(McpJsonSchemaGenerator) |
直接复用 ToolDefinition.inputSchema()(第 9 章的 JsonSchemaGenerator 产物) |
| 参数绑定 | 按名反射绑定,支持上下文注入 | jsonHelper.toJson(request.arguments()) → 整包当 JSON 字符串传给 ToolCallback.call |
| 上下文注入 | Exchange / McpMeta / progressToken / CallToolRequest |
只通过 ToolContext(Map.of("exchange", ...)) 传一个 key(McpToolUtils:80、:264) |
| 异常语义 | 三类 Throwable 三种归宿(14.7) | catch (Exception e) 一律转 isError=true (:281-286) |
最后一行是最大的差异 :ToolCallback 路径没有 McpError 硬抛的概念 。你的工具抛 McpError,在这里会被 catch (Exception e) 捕获,变成一句 isError=true 的文本结果------与注解路径的行为完全相反 。如果你依赖 McpError 触发 elicitation 之类的协议流程,只能用 @McpTool。
异常处理还有一个细节:catch (Exception e) 捕获的是 Exception,所以 Error 不会被捕获(会冒泡)------这点与注解路径一致。
14.8.2 mimeType 与图片返回
mimeType 来自 spring.ai.mcp.server.tool-response-mime-type.<toolName>(McpServerProperties:162、:215-217),在转换器里读取:
java
MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName))
? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null;
(ToolCallbackConverterAutoConfiguration:72-73)
只有当 mimeType.toString().startsWith("image") 时才走 ImageContent 分支,否则一律 TextContent。也就是说:
yaml
spring:
ai:
mcp:
server:
tool-response-mime-type:
generate_chart: image/png # → ImageContent(base64)
query_data: application/json # → 仍是 TextContent!只是内容是 JSON 字符串
application/json 不会 产生结构化内容------它只是把 callResult(本身就是字符串)放进 TextContent。想拿 structuredContent,只能走注解路径。
Annotations.audience(List.of(Role.ASSISTANT)) 是 MCP 的内容受众声明,表示"这段内容是给模型看的"。
14.8.3 ASYNC 是"伪异步"
java
public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification(ToolCallback toolCallback,
@Nullable MimeType mimeType) {
McpServerFeatures.SyncToolSpecification syncToolSpecification = toSyncToolSpecification(toolCallback, mimeType);
return McpServerFeatures.AsyncToolSpecification.builder()
.tool(syncToolSpecification.tool())
.callHandler((exchange, request) -> Mono
.fromCallable(() -> syncToolSpecification.callHandler()
.apply(new McpSyncServerExchange(exchange), request))
.subscribeOn(Schedulers.boundedElastic()))
.build();
}
(McpToolUtils:385-397)
把这段翻译成人话:先构造一个同步 spec,然后用 Mono.fromCallable 包住它的阻塞调用,丢到 boundedElastic 线程池上跑。
这意味着:
spring.ai.mcp.server.type=ASYNC+ToolCallback工具 ≠ 非阻塞。 你的@Tool方法里如果有 JDBC、阻塞 HTTP、文件 IO,它们仍然会阻塞一个线程,只不过这个线程是 boundedElastic 里的。- boundedElastic 有上限 (默认
10 × CPU 核数),打满后新请求会排队,超过Schedulers.boundedElastic()的队列上限(默认 100000)会抛RejectedExecutionException。 - javadoc(
:344-356)里还写着 "Execute the tool without blocking the calling thread"、"Provide backpressure through Project Reactor"------前一句成立(调用线程不阻塞),后一句基本不成立(没有背压,只有线程池排队)。
选型建议 :如果你的工具是真正响应式的(WebClient、R2DBC),用
@McpTool+Mono返回(ASYNC 注解路径),那是真的非阻塞。如果你的工具是阻塞的,type=SYNC反而更诚实------至少你能看到它占的是哪个线程、能用 Servlet 的线程池配置去调。把它伪装成 ASYNC 只会把瓶颈挪到你更难观测的地方。
STATELESS 的 ASYNC 版本(toStatelessAsyncToolSpecification,:399-409)是同样的结构,只是包装对象换成 McpStatelessServerFeatures.SyncToolSpecification。
14.8.4 ToolCallbackUtils:聚合与 MCP 工具排除
java
static List<ToolCallback> aggregateToolCallbacks(ObjectProvider<List<ToolCallback>> toolCalls,
List<ToolCallback> toolCallbackList, ObjectProvider<List<ToolCallbackProvider>> tcbProviderList,
ObjectProvider<ToolCallbackProvider> tcbProviders, boolean includeMcpTools) {
var allToolCallbacks = Stream.concat(toolCalls.stream().flatMap(List::stream), toolCallbackList.stream())
.filter(toolCallback -> includeMcpTools || !isMcpToolCallback(toolCallback));
var allCallbackProviders = Stream.concat(tcbProviderList.stream().flatMap(List::stream), tcbProviders.stream());
AtomicBoolean hasExcludedToolProvider = new AtomicBoolean(false);
var filteredProviders = allCallbackProviders.filter(provider -> {
var includeProvider = includeMcpTools || !isMcpToolProvider(provider);
if (!includeProvider) { hasExcludedToolProvider.set(true); }
return includeProvider;
}).distinct();
var toolCallbacksFromProviders = filteredProviders.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream).filter(Objects::nonNull);
var toolCallbacks = Stream.concat(allToolCallbacks, toolCallbacksFromProviders).toList();
if (log.isWarnEnabled() && hasExcludedToolProvider.get()) {
log.warn("Found MCP Clients. The MCP Client tools will not be exposed by the MCP Server. "
+ "If you would like to expose the tools, set " + McpServerProperties.CONFIG_PREFIX
+ ".expose-mcp-client-tools=true.");
}
return toolCallbacks;
}
(ToolCallbackUtils:44-72)
两个排除判据:
java
static boolean isMcpToolCallback(ToolCallback toolCallback) {
return (toolCallback instanceof SyncMcpToolCallback) || (toolCallback instanceof AsyncMcpToolCallback);
}
static boolean isMcpToolProvider(ToolCallbackProvider tcbp) {
return (tcbp instanceof org.springframework.ai.mcp.SyncMcpToolCallbackProvider)
|| (tcbp instanceof org.springframework.ai.mcp.AsyncMcpToolCallbackProvider);
}
(:74-81)
即:默认情况下,MCP 客户端提供的工具不会被这个 MCP 服务端再暴露一遍。 这是防环路的必要措施------否则"服务端转发下游工具"会造成无限递归(spring.ai.mcp.server.expose-mcp-client-tools=true 时才打开,McpServerProperties:106、:119-125)。
对照第 13 章 13.1 里"MCP provider 被按类名字符串排除出 ToolCallbackResolver"------那是在客户端侧 防 Spring AI 工具解析;这里是在服务端侧 防环路。两处的判据类型不同(一个是字符串比较,一个是 instanceof),但目的一致。
.distinct()(:58)依赖 ToolCallbackProvider 的 equals------多数实现没有覆写,等于引用去重。
还有一个有意思的细节:ToolCallbackConverterAutoConfiguration 里有一个未被使用的私有静态方法 (:110-113):
java
private static boolean isMcpToolProvider(ToolCallbackProvider tcbp) {
return !(tcbp instanceof org.springframework.ai.mcp.SyncMcpToolCallbackProvider)
&& !(tcbp instanceof org.springframework.ai.mcp.AsyncMcpToolCallbackProvider);
}
注意它的逻辑是 取反 的(返回"是不是非 MCP provider"),而且没有任何调用点------真正干活的是 ToolCallbackUtils.isMcpToolProvider。这是一段残留的死代码,但它有个副作用:如果你复制这段代码去用,会得到相反的语义。
14.9 两条来源的汇聚:谁赢了?
14.9.1 汇聚点的代码
java
ObjectProvider<List<SyncToolSpecification>> tools, // McpServerAutoConfiguration:110
...
List<SyncToolSpecification> toolSpecifications = new ArrayList<>(
tools.stream().flatMap(List::stream).toList()); // :138-139
ObjectProvider<List<X>>.stream() 返回的是所有类型为 List<X> 的 Bean ,flatMap(List::stream) 把它们全部拍平。所以最终的工具集 = 注解来源 + ToolCallback 来源,直接拼接。
14.9.2 去重只发生在一侧
ToolCallback 侧(两个 ToolCallbackConverter*AutoConfiguration):
java
return tools.stream()
.collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool,
(existing, replacement) -> existing)) // 重名时保留先出现的
.values().stream()
.map(tool -> ... McpToolUtils.toSyncToolSpecification(tool, mimeType))
.toList();
(ToolCallbackConverterAutoConfiguration:64-76)
Collectors.toMap 的第三个参数是 merge function,(existing, replacement) -> existing 就是"保留先来的 "。但注意:toMap 的结果放进的是 HashMap(默认实现),所以"先来的"是在 HashMap 中的插入顺序决定的那个 ------也就是原 stream 的顺序。而原 stream 的顺序又取决于 List<ToolCallback> Bean 的顺序、Bean 的初始化顺序......这个"先"并不稳定。
换句话说:如果你的两个
ToolCallback工具重名,哪个生效是不确定的。不要依赖它,直接改名字。
注解侧(SyncMcpToolProvider):完全没有去重 。sorted(Comparator.comparing(Method::getName)) 之后直接 map,同名方法(重载!)会产生同名工具。
重载方法是重名工具最常见的来源 :同一个类里写
@McpTool的foo(String)和foo(String, int),两个都会注册,名字都叫foo(除非你显式写name)。
14.9.3 跨来源冲突:没有检查
拼接发生在 flatMap,拼接之前 没有任何 Set 校验。所以:
java
@Service
public class DupService {
@McpTool(name = "search", description = "注解版检索")
public String search(@McpToolParam String q) { return "A:" + q; }
}
@Configuration
public class ToolConfig {
@Bean
public ToolCallback searchTool() { // Spring AI @Tool,名字也是 search
return MethodToolCallback.builder()...name("search")...build();
}
}
两个 search 会一起进 serverBuilder.tools(specifications)。SDK 的 McpServer 内部如何处理重名工具(抛异常?后者覆盖前者?还是都出现在 tools/list 里?)------本仓库看不到 SDK 源码,不做断言。但无论哪种结果都不是你想要的:
- 若后者覆盖 →
tools/list只有一个,行为取决于 Bean 顺序; - 若都保留 →
tools/list有两个同名工具,客户端侧(第 13 章的DefaultMcpToolNamePrefixGenerator)在做消歧时会对同名工具加alt_1_后缀,但那是客户端的事,且它是"伪幂等"的(13.5); - 若抛异常 → 启动失败,至少是显式的。
建议 :给工具名加统一前缀(比如 weather_、order_),并在集成测试里断言 tools/list 的大小与名字集合。下面这段断言很便宜:
java
@Test
void toolNamesAreUnique(/* 你的 MCP 客户端 */) {
var names = client.listTools().stream().map(t -> t.name()).toList();
assertEquals(names.size(), new HashSet<>(names).size(), "存在重名工具: " + names);
}
14.9.4 关于 validateToolInputs
第 13 章预告里提到"MCP SDK 2.0 的入参校验默认开启(validateToolInputs)"。核对结论:
ruby
$ grep -rn "validateToolInputs" --include=*.java .
(零命中)
Spring AI 2.0.1 的源码中没有任何地方 出现这个标识符,也没有对应的配置属性(对比 spring.ai.mcp.server.* 的全部属性:见 14.13.1 的表,没有它)。所以在 Spring AI 这一层:
- 没有开关可以关/开它;
- Spring AI 传给 SDK 的是
McpSchema.Tool(含inputSchema字符串)与一个callHandler函数,校验发生在 SDK 内部; - 本章能给出的建议只有一条:保证你的
inputSchema是完整、合法、且required准确的 (14.5 全节都在讲这件事)。如果 SDK 侧真有严格校验,那么 14.5.6 提到的"没有-parameters导致属性名变成arg0"会变成硬失败而不是静默------从某种角度看反而是好事。
14.9.5 完整的工具名决策链
把第 13 章与本节的结论合并,一个工具名从"Java 方法"到"模型看到的字符串"经过了:
ini
Java 方法名 / @McpTool(name=...)
↓ 服务端:无格式化、无截断、无非法字符过滤、无长度限制
MCP 工具名(tools/list 里的 name)
↓ 客户端:McpToolUtils.format() 删除非法字符(13.5)+ 前缀拼接
↓ 客户端:DefaultMcpToolNamePrefixGenerator 消歧(伪幂等,13.5)
↓ 客户端:McpToolUtils.prefixedToolName 的 64 字符尾部截断(仅废弃构造函数路径)
Spring AI ToolDefinition.name
↓ 第 11 章:internalToolExecutionEnabled 全库零命中,循环在 advisor 里
模型看到的工具名
服务端的"什么都不做"是这一整条链里最弱的一环 ------所有补救都在客户端。如果你的 MCP 服务端会被第三方客户端 (Claude Desktop、Cursor、自研非 Spring AI 客户端)接入,客户端那套补救不存在 ,工具名就是你写的原样。所以:自己保证名字合法。
14.10 服务端的观测与 AOT:与客户端对称的两个空白
第 13 章 13.11 讲过"客户端没有任何自有观测"。把这个检查在服务端重做一遍:
shell
$ grep -rln "Observation" mcp/ auto-configurations/mcp/ --include=*.java | grep -v test
(零命中)
结论一样:MCP 服务端同样没有任何自有观测埋点。
具体来说,一次 tools/call 请求从"HTTP 请求进入 /mcp"到"CallToolResult 写回",中间经过:
css
RouterFunction(Spring MVC/WebFlux 的 Web 观测✅)
→ WebMvcStreamableServerTransportProvider.handlePost (❌ 无 span)
→ session.responseStream(...) (❌ 无 span,SDK 内部)
→ SyncMcpToolMethodCallback.apply (❌ 无 span)
→ 你的 @McpTool 方法 (❌ 无 span,除非你自己加 @Observed)
只有最外层 Spring Web 的 HTTP 观测是有的。你无法回答"哪个工具被调用了多少次 / 平均耗时多少 / 错误率多少"------这是 MCP 服务端在生产上最常被问的问题,而框架没有给任何东西。
可做的补救(按成本从低到高):
-
在工具方法上加
@Observed(Spring Boot 的 AOP 观测,observationRegistry自动接入)。成本最低,粒度是"每个工具一个 span"。 -
自定义
McpSyncServerCustomizer包装instructions之类,无法插入 handler------它只能改 builder,不能包 handler ,所以这条路走不通(注意McpSyncServerCustomizer.customize(SyncSpecification)拿到的是 builder,不是已注册的 handler 列表)。 -
在
SyncToolSpecification的 callHandler 上做包装 :自己定义List<SyncToolSpecification>Bean(覆盖自动配置的),对每个 spec 的 handler 包一层:java@Bean @Primary public List<McpServerFeatures.SyncToolSpecification> observedToolSpecs( List<McpServerFeatures.SyncToolSpecification> origin, ObservationRegistry registry) { return origin.stream().map(spec -> { String name = spec.tool().name(); return McpServerFeatures.SyncToolSpecification.builder() .tool(spec.tool()) .callHandler((exchange, request) -> Observation.createNotStarted("mcp.tool." + name, registry) .observe(() -> spec.callHandler().apply(exchange, request))) .build(); }).toList(); }注意这样做会再产生一个
List<SyncToolSpecification>Bean ,与自动配置的两个一起被flatMap拼进去------于是每个工具出现两次 (14.9.3 的重名问题)。正确做法是不要 新增 Bean,而是定义一个BeanPostProcessor或者干脆禁用tool-callback-converter并自己全量接管。这条路的复杂度不低,实际项目里方案 1 更划算。 -
在反向代理/网关层做统计 (按
POST /mcp的 QPS/耗时),粒度粗但零侵入。
14.10.1 AOT:服务端比客户端少了一层
客户端侧有三个 aot.factories:
bash
auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/resources/META-INF/spring/aot.factories
→ McpClientAutoConfigurationRuntimeHints
auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/.../aot.factories
→ McpClientAutoConfigurationRuntimeHints
auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-webflux/.../aot.factories
→ McpClientAutoConfigurationRuntimeHints
它们做的事(McpClientAutoConfigurationRuntimeHints:31-38):
java
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
hints.resources().registerPattern("**.json");
var mcs = MemberCategory.values();
for (var tr : findJsonAnnotatedClassesInPackage("org.springframework.ai.mcp.client.common.autoconfigure")) {
hints.reflection().registerType(tr, mcs);
}
}
即:注册 **.json 资源 + 扫描包内所有带 JSON 注解的类(配置属性类、DTO)注册反射。
服务端侧一个都没有:
shell
$ find auto-configurations/mcp -path "*server*" -name "aot.factories"
(零命中)
$ grep -rn "RegisterReflectionForBinding" mcp/ auto-configurations/mcp/ --include=*.java
(零命中)
服务端在 native 下能拿到的 hints 只有两个来源:
| 来源 | 注册内容 |
|---|---|
McpHints(mcp/common/.../aot/McpHints.java,经 mcp/common/src/main/resources/META-INF/spring/aot.factories 注册) |
McpSchema 的所有内部类 (AiRuntimeHints.findInnerClassesFor(McpSchema.class)) |
McpServerAnnotationScannerAutoConfiguration 的 AnnotationHints(:98-105)与 AOT processor(14.3.4) |
四个注解类型 + 含注解方法的 Bean 类(全成员)+ MetaProvider 实现类的构造器 |
缺的是"配置属性类与工具 DTO"这一层。 McpServerProperties、McpServerSseProperties、McpServerStreamableHttpProperties、McpServerChangeNotificationProperties 都没有被注册(客户端的对应类有)。加上 14.3.4 提到的"参数/返回 DTO 也没被注册",意味着 native 构建下你需要自己补:
java
@RegisterReflectionForBinding({
Forecast.class, // 工具的返回 DTO
ForecastRequest.class // 工具的入参 DTO
})
@SpringBootApplication
public class McpServerApplication { ... }
顺带一提
McpHints上的@SuppressWarnings("unused")(:46):它是个"只被aot.factories反射引用、代码里没有调用点"的类,加这个注解是为了消除 IDE 警告。如果你在代码里grep McpHints只找到定义和aot.factories,不要以为它是死代码------它是生效的。
14.11 陷阱总表
按"踩到的概率 × 排查难度"排序。
| # | 现象 | 根因 | 位置 |
|---|---|---|---|
| 1 | 工具一个都没注册,日志只有 No tool methods found |
@McpTool 写在接口 方法上;BPP 用 findAnnotation 能扫到,provider 用 isAnnotationPresent 扫不到 |
AnnotatedMethodDiscovery:40 vs SyncMcpToolProvider:70 |
| 2 | 改成 type=ASYNC 后工具全部消失 |
ASYNC provider 只保留响应式 返回类型;SYNC provider 只保留非响应式 | McpPredicates:55-79 |
| 3 | 改成 STATELESS 后工具消失 | 带 Exchange / RequestContext 参数的方法被 filterMethodWithBidirectionalParameters 丢弃 |
McpPredicates:96-107、SyncStatelessMcpToolProvider:75 |
| 4 | 模型总是传错参数 / 不传参数 | 缺少 -parameters → 属性名变成 arg0 |
McpJsonSchemaGenerator:156、AbstractMcpToolMethodCallback:139 |
| 5 | 配了 protocol 但走的不是你配的那个 |
protocol 未配置时 SSE 条件 matchIfMissing=true 胜出;字段默认值 STREAMABLE 与实际不符 |
见第 15 章 15.1 |
| 6 | 启动报 NoUniqueBeanDefinitionException: McpSyncServerCustomizer |
SERVLET WebMVC + type=SYNC 下已有一个 servletMcpSyncServerCustomizer;Optional<T> 不解决多候选 |
McpServerAutoConfiguration:116、:223-225 |
| 7 | 可选参数传 null 时报 argument type mismatch |
基本类型(int/boolean)拿到 null |
AbstractMcpToolMethodCallback:151-153 |
| 8 | Flux 返回值只生效第一个元素 |
convertToCallToolResult 对 Flux 用 .next() |
AbstractAsyncMcpToolMethodCallback:119 |
| 9 | ASYNC + Mono<List<T>> + generateOutputSchema=true 启动抛 ClassCastException |
generateFromClass((Class<?>) typeArgument) 把 ParameterizedType 强转成 Class |
AsyncMcpToolProvider:129-136 |
| 10 | 两个来源的同名工具冲突,行为不确定 | 注解侧不去重、ToolCallback 侧去重但顺序不稳、跨来源无校验 |
14.9.2 / 14.9.3 |
| 11 | 用 @McpArg 修饰工具参数,Schema 里变成"必填无描述" |
@McpArg 只服务 @McpPrompt,工具用 @McpToolParam |
14.5.5 |
| 12 | 不写 description,工具描述是空串 |
toolDescription = toolJavaAnnotation.description(),无回退 |
SyncMcpToolProvider:80 |
| 13 | 工具名里带 .、空格等非法字符被严格客户端拒绝 |
服务端不做任何名字格式化/截断/校验 | SyncMcpToolProvider:77-78 |
| 14 | void 工具返回 "Done"(带引号) |
jsonHelper.toJson("Done") |
AbstractMcpToolMethodCallback:180 |
| 15 | 异常消息把 SQL/内部 URL 泄漏给模型 | createSyncErrorResult 拼接 e.getMessage() + 根因消息 |
AbstractSyncMcpToolMethodCallback:73-79 |
| 16 | McpError 在 ToolCallback 工具里不冒泡,变成 isError 文本 |
ToolCallback 路径是 catch (Exception e) 一把抓 |
McpToolUtils:281-286 |
| 17 | SYNC / ASYNC 的错误文案格式不一致 | 三处拼接逻辑各不相同 | 14.7.2 |
| 18 | 多层异常链 A→B→A 导致死循环 | findCauseUsingPlainJava 只防自引用不防环 |
AbstractMcpToolMethodCallback:225 |
| 19 | 自定义 MetaProvider 在 native 下启动失败 |
需要无参构造 + 被反射实例化;需要 AOT 注册 | MetaUtils:70-78、AOT :93-106 |
| 20 | native 下工具入参/出参 DTO 反序列化失败 | AOT processor 只注册 Bean 类本身,不注册 DTO | 14.3.4、14.10.1 |
| 21 | 服务端配置属性类在 native 下绑定失败 | 服务端模块没有 aot.factories(客户端有三个) |
14.10.1 |
| 22 | POST /mcp 返回 400 |
未同时 Accept: application/json, text/event-stream |
见第 15 章 15.3 |
| 23 | 会话泄漏,内存缓慢上涨 | sessionIdleTimeout 默认 null(不驱逐),且无配置属性可改 |
见第 15 章 15.3.3 |
| 24 | 多实例部署随机 404 Session not found |
会话是进程内 ConcurrentHashMap |
见第 15 章 15.3.2 |
| 25 | stdio=true + protocol=STATELESS 什么都没装配,日志全无 |
两个 server 配置的 @Conditional 都不成立 |
见第 15 章 15.1.3 |
| 26 | 无法统计工具调用次数/耗时 | 服务端零观测埋点 | 14.10 |
| 27 | 自定义 McpSyncServerCustomizer 在 STATELESS 下不生效 |
无状态 server 方法签名里没有 customizer 参数 | 见第 15 章 15.4.3 |
| 28 | ToolCallback 工具拿不到 outputSchema/title/hints |
ToolCallback 转换路径只设 name + description + inputSchema |
McpToolUtils:254-259 |
| 29 | type=ASYNC + ToolCallback 工具仍然阻塞 |
Mono.fromCallable + boundedElastic 包装阻塞调用 |
McpToolUtils:390-396 |
| 30 | spring.ai.mcp.server.change-notification.* 配了没效果 |
STATELESS 下硬编码 false,且属性类未引入 | 见第 15 章 15.4.2 |
14.12 本章小结
-
服务端有两条工具来源,且互不知情。 注解路径(
McpServerSpecificationFactoryAutoConfiguration)与ToolCallback路径(ToolCallbackConverterAutoConfiguration)产出同类型 的List<SyncToolSpecification>Bean,被ObjectProvider+flatMap直接拼接。去重只发生在ToolCallback侧(toMap保首个),注解侧不去重,跨来源不校验。 -
注解扫描靠 BPP + AOT 两条腿。 BPP 用
findAnnotation(支持元注解)注册 Bean,provider 用isAnnotationPresent(不 支持接口继承)------这个不对称是"Bean 注册成功但零工具"的根因。AOT 侧只注册 Bean 类本身与MetaProvider,DTO 与配置类需要你自己补。 -
@McpTool→SyncToolSpecification的全部决策在SyncMcpToolProvider:66-159这 90 行里 :名字无格式化、描述无回退、title 三级回退、generateOutputSchema默认 false、ReturnMode先判 outputSchema 再判 void。ASYNC 版在 outputSchema 与错误文案上有实质差异(含一个ClassCastException缺口)。 -
Schema 生成严重依赖
-parameters。 属性名与绑定 key 都来自parameter.getName(),缺这个参数时 Schema 与绑定自洽但对模型不可读,且完全静默。 -
结果转换有六条分支 (
CallToolResult直通 / VOID→"Done"/ STRUCTURED→JSON 往返 / null→"null"/String直通 / 其他→JSON),Flux被.next()截断成单值。 -
异常语义三类归宿 :
McpError与受检异常(UndeclaredThrowableException)硬抛到协议层,其他RuntimeException变成isError=true给模型看。这个契约与 Spring AI@Tool对齐,但ToolCallback路径破坏 了它(一把catch (Exception))。 -
ToolCallback路径是"够用但不完整":没有 title/hints/_meta/outputSchema,参数整包 JSON 传入,ASYNC 是伪异步(boundedElastic)。 -
服务端零观测、AOT 比客户端少一层。 两个空白都可以在应用层补,但框架没有给。
14.13 思考题
-
你把一个
@McpTool方法从public String foo(String a)改成public Mono<String> foo(String a),同时把spring.ai.mcp.server.type从SYNC改成ASYNC。启动时日志里没有 WARN,但tools/list里少了一个工具。请列出所有可能的原因,并给出逐个排查的命令。 -
ToolCallbackConverterAutoConfiguration里那个私有的isMcpToolProvider(:110-113)逻辑是取反 的,且没有任何调用点。如果有人把它"修复"成不取反并接进aggregateToolCallbacks,系统会出现什么行为变化?(提示:想想暴露下游 MCP 工具的场景。) -
一个
@McpTool方法签名为public CallToolResult foo(@McpToolParam String a),且注解上写了generateOutputSchema = true。它的ReturnMode是什么?outputSchema会不会生成?为什么这样设计是自洽的? -
你的工具抛了一个
McpError,客户端收到的是"工具返回了错误文本"而不是"协议错误"。列出三种可能导致这个现象的配置/代码组合。 -
生产环境要求"每个 MCP 工具的调用次数、P99 耗时、错误率"可观测。基于 14.10 的分析,给出两套方案(成本最低的与粒度最细的),并说明各自会引入什么副作用。
-
如果把
spring.ai.mcp.server.expose-mcp-client-tools设为true,同时在同一个应用里既做 MCP 服务端又做 MCP 客户端,且下游服务端的工具名与本地@McpTool的工具名相同------会发生什么?结合 14.9 与第 13 章 13.5 回答。 -
为什么
McpServerAnnotationScannerAutoConfiguration的两个基础设施 Bean 方法必须声明为static?如果去掉static会怎样?
14.14 本章文件索引
自动配置(common)
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java(403 行).../McpServerStatelessAutoConfiguration.java(274 行).../McpServerJsonMapperAutoConfiguration.java(71 行).../McpServerStdioDisabledCondition.java(48 行).../ToolCallbackConverterAutoConfiguration.java(135 行).../StatelessToolCallbackConverterAutoConfiguration.java(129 行).../ToolCallbackUtils.java(83 行).../annotations/McpServerAnnotationScannerAutoConfiguration.java(107 行).../annotations/McpServerAnnotationScannerProperties.java(39 行).../annotations/McpServerSpecificationFactoryAutoConfiguration.java(137 行).../annotations/StatelessServerSpecificationFactoryAutoConfiguration.java(136 行).../properties/McpServerProperties.java(272 行).../properties/McpServerChangeNotificationProperties.java(80 行).../properties/McpServerSseProperties.java(90 行).../properties/McpServerStreamableHttpProperties.java(71 行).../src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(8 行)
自动配置(webmvc / webflux)
auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc/src/main/java/.../McpServerSseWebMvcAutoConfiguration.java(100 行).../McpServerStreamableHttpWebMvcAutoConfiguration.java(74 行).../McpServerStatelessWebMvcAutoConfiguration.java(69 行)auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux/src/main/java/.../下同名三个
注解与回调(mcp/mcp-annotations)
annotation/McpTool.java(123)、annotation/McpToolParam.java(43)、annotation/McpArg.java(50)annotation/method/tool/ReturnMode.java(23)annotation/method/tool/AbstractMcpToolMethodCallback.java(242)annotation/method/tool/AbstractSyncMcpToolMethodCallback.java(93)annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java(212)annotation/method/tool/SyncMcpToolMethodCallback.java(119)annotation/method/tool/AsyncMcpToolMethodCallback.java(123)annotation/method/tool/SyncStatelessMcpToolMethodCallback.java(110)、AsyncStatelessMcpToolMethodCallback.java(121)annotation/method/tool/ReactiveUtils.java(147)annotation/method/tool/utils/McpJsonSchemaGenerator.java(297)annotation/method/tool/utils/McpSpringAiSchemaModule.java(59)annotation/provider/tool/AbstractMcpToolProvider.java(79)、SyncMcpToolProvider.java(161)、AsyncMcpToolProvider.java(168)、SyncStatelessMcpToolProvider.java(165)、AsyncStatelessMcpToolProvider.java()annotation/common/McpPredicates.java(109)、annotation/common/MetaUtils.javaannotation/spring/SyncMcpAnnotationProviders.java(360)、AsyncMcpAnnotationProviders.java(361)、AnnotationProviderUtil.java(59)annotation/spring/scan/AnnotatedMethodDiscovery.java(48)、AbstractMcpAnnotatedBeans.java(57)、AbstractAnnotatedMethodBeanPostProcessor.java(56)、AbstractAnnotatedMethodBeanFactoryInitializationAotProcessor.java(108)
common / 传输
mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java(486)mcp/common/src/main/java/org/springframework/ai/mcp/aot/McpHints.java(66)mcp/common/src/main/resources/META-INF/spring/aot.factoriesmcp/transport/mcp-spring-webmvc/src/main/java/.../WebMvcStreamableServerTransportProvider.java(999)、WebMvcStatelessServerTransport.java(313)、WebMvcSseServerTransportProvider.java(686)、HeaderUtils.java(45)mcp/transport/mcp-spring-webflux/src/main/java/.../下同名四个(748 / 285 / 657 / 49)
Starter
starters/spring-ai-starter-mcp-server/pom.xml、...-webmvc/pom.xml、...-webflux/pom.xml
对比参照(客户端,第 13 章)
auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/.../aot/McpClientAutoConfigurationRuntimeHints.java- 三个客户端模块的
aot.factories
14.15 复核命令
bash
# 1) 服务端自动配置清单
cat auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
# 2) protocol 的字段默认值 vs 条件判定
sed -n '96,101p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/properties/McpServerProperties.java
sed -n '343,401p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 3) 兜底的 STDIO transport(无条件、只有 @ConditionalOnMissingBean)
sed -n '88,102p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 4) SYNC/ASYNC 的双向过滤
sed -n '47,79p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/McpPredicates.java
sed -n '68,76p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java
sed -n '70,76p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java
sed -n '71,76p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProvider.java
# 5) @McpTool → SyncToolSpecification 全段
sed -n '66,160p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java
# 6) outputSchema 的两种生成(SYNC 泛型安全 / ASYNC 可能 CCE)
sed -n '119,144p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java
sed -n '121,146p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java
# 7) Schema 生成:参数名来源 + $defs 提升 + required 回退
sed -n '113,212p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java
sed -n '250,296p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/utils/McpJsonSchemaGenerator.java
# 8) @McpArg 只服务 Prompt
grep -rn "McpArg" --include=*.java mcp/mcp-annotations/src/main | grep -v "/test/"
# 9) 结果转换六分支 + Flux 截断
sed -n '171,202p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java
sed -n '80,146p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java
# 10) 异常语义:三 catch 顺序 + 错误文案
sed -n '87,117p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/SyncMcpToolMethodCallback.java
sed -n '62,92p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractSyncMcpToolMethodCallback.java
sed -n '168,176p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractAsyncMcpToolMethodCallback.java
# 11) ToolCallback 转换:能力缺失 + 伪异步
sed -n '251,288p' mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java
sed -n '385,409p' mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java
# 12) 两条来源的汇聚与去重
sed -n '46,77p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfiguration.java
sed -n '104,117p;132,145p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java
# 13) 死代码:取反且无调用点的 isMcpToolProvider
sed -n '108,114p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfiguration.java
sed -n '74,82p' auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackUtils.java
# 14) BPP 与 provider 的注解查找不对称
sed -n '34,46p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/spring/scan/AnnotatedMethodDiscovery.java
sed -n '44,55p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/spring/scan/AbstractAnnotatedMethodBeanPostProcessor.java
sed -n '33,58p' mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/spring/AnnotationProviderUtil.java
# 15) validateToolInputs 零命中
grep -rn "validateToolInputs" --include=*.java . || echo "ZERO HIT"
# 16) 服务端零观测 + AOT 缺层
grep -rln "Observation" mcp/ auto-configurations/mcp/ --include=*.java | grep -v test || echo "ZERO HIT"
find auto-configurations/mcp -path "*server*" -name "aot.factories" || echo "NO SERVER aot.factories"
find auto-configurations/mcp -path "*client*" -name "aot.factories"
cat mcp/common/src/main/resources/META-INF/spring/aot.factories
14.16 下一章预告
本章解决的是"工具从哪来 ":注解怎么被扫、SyncToolSpecification 怎么被造出来、Schema 怎么生成、返回值怎么变成 CallToolResult、异常怎么分三类归宿、以及 ToolCallback 这条平行路径差在哪。
第 15 章解决"请求怎么进来、状态怎么留":
- 三种协议形态的完整条件矩阵 :
protocol字段默认值与实际生效值为什么相反;stdio/SSE/STREAMABLE/STATELESS两两组合的七种结果,包括两种"静默什么都不装配"的组合。 - Streamable HTTP 传输的源码级剖析 :
Mcp-Session-Id的发放与校验、GET/POST/DELETE三个端点各自的校验规则、Last-Event-ID断线重放的实现、以及maxSessions=100_000与sessionIdleTimeout=null这两个无法通过配置属性修改的默认值带来的会话泄漏风险。 - STATELESS 的一整套降级 :
GET为什么是 405、能力为什么全部硬编码 false、为什么McpSyncServerCustomizer与roots都不注入、以及immediateExecution在 WebMVC 与 WebFlux 下为什么不同。 - SYNC / ASYNC 的选型对照 ,以及
Optional<McpSyncServerCustomizer>那个会让应用启动失败的多候选陷阱。
第 15 章:MCP 服务端(下)------三种协议形态、Streamable 传输与状态管理。