Spring AI 2.0.1 深度解析-第 14 章 MCP 服务端(上):注解式实现与工具来源

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 本章的五句总纲

  1. 服务端有两条工具来源,且互不知情。 注解路径由 McpServerSpecificationFactoryAutoConfiguration 产出 List<SyncToolSpecification>;ToolCallback 路径由 ToolCallbackConverterAutoConfiguration 产出同类型 的另一个 Bean。McpServerAutoConfiguration 用 ObjectProvider<List<SyncToolSpecification>> + flatMap 把两者拼起来(McpServerAutoConfiguration:110、:138-139)。拼接前,ToolCallback 路径做了按名去重(Collectors.toMap(..., (existing, replacement) -> existing),ToolCallbackConverterAutoConfiguration:64-67),注解路径没有任何去重,两个来源之间也没有。

  2. 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)才是默认装配的那一个。

  3. 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)。

  4. Schema 生成严重依赖 -parameters 编译参数。 McpJsonSchemaGenerator.internalGenerateFromMethodArguments 用 parameter.getName() 作为属性名(:156),AbstractMcpToolMethodCallback.buildMethodArguments 用 parameter.getName() 作为入参绑定的 key(:139)。没有 -parameters 时 Java 反射只能拿到 arg0/arg1,Schema 与绑定会同时 失效,而且不报错------工具只会安静地收到一堆 null。

  5. 服务端的 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 priority McpServerTransportProviderBase bean 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 为例,它的执行顺序是:

  1. 构造 serverInfo :Implementation.builder(name, version).build(),取 spring.ai.mcp.server.name(默认 mcp-server)与 .version(默认 1.0.0)(:118-120、McpServerProperties:72、:77)。
  2. 按 transport 类型分派 :transportProvider instanceof McpStreamableServerTransportProvider → McpServer.sync((McpStreamableServerTransportProvider) t),否则 McpServer.sync((McpServerTransportProvider) t)(:122-129)。这是个 instanceof 分派,说明有状态服务端对两种传输的支持是 SDK 层面的两套 Specification。
  3. 按 capability 装配四类规格 :tools / resources / resourceTemplates / prompts / completions,每一类都是"capabilities 开关为真 → 往 capabilitiesBuilder 上登记 → ObjectProvider 里取出所有 List<...> 并 flatMap 拍平 → 非空则 serverBuilder.xxx(...)"(:132-200)。
  4. roots 变更处理器 :rootsChangeConsumers.ifAvailable(...)(:202-207)。
  5. 收尾三件套 :capabilities(capabilitiesBuilder.build())、instructions(...)、requestTimeout(...)(:209-213)。
  6. 自定义钩子 :mcpSyncServerCustomizer.ifPresent(customizer -> customizer.customize(serverBuilder))(:214)。
  7. 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)

三个细节:

  1. AopUtils.getTargetClass(bean) 而不是 bean.getClass()------被 @Transactional / @Cacheable 代理过的 Bean 也能正确扫到目标类上的注解。
  2. 注册的是 bean 实例 (addMcpAnnotatedBean(bean, ...))而不是 Class。后续 SyncMcpToolMethodCallback 反射调用时用的是这个实例(:142),所以工具方法里的依赖注入是正常生效的,且单例语义与 Spring 一致。
  3. 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));
        }
    });
}

差异有三处值得记:

  1. 它不解包到原始返回类型,而是取泛型实参 ------Mono<Forecast> → 取 Forecast。

  2. 用的是 generateFromClass 而不是 generateFromType 。看 McpJsonSchemaGenerator:

    java 复制代码
    public 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,这没问题,但意味着两个方法的缓存是混在一起的。

  3. 它对 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......

此时会发生什么?

  1. Schema 的属性名变成 arg0、arg1,模型看到的是 {"arg0": "string"}------它当然不知道该填什么,可能乱填也可能不填;
  2. 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)

注入优先级(从上到下):

  1. McpSyncRequestContext / McpAsyncRequestContext → 构造 DefaultMcpSyncRequestContext.builder().request(request).exchange(exchange).build()(SyncMcpToolMethodCallback:69-71)
  2. @McpProgressToken 标注的参数 → request.progressToken()
  3. McpMeta → new McpMeta(request.meta())
  4. CallToolRequest → 原始请求
  5. McpTransportContext → exchange.transportContext()
  6. McpSyncServerExchange / McpAsyncServerExchange(由子类 isExchangeOrContextType 判定)→ exchange
  7. 兜底 :按参数名从 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 线程池上跑。

这意味着:

  1. spring.ai.mcp.server.type=ASYNC + ToolCallback 工具 ≠ 非阻塞。 你的 @Tool 方法里如果有 JDBC、阻塞 HTTP、文件 IO,它们仍然会阻塞一个线程,只不过这个线程是 boundedElastic 里的。
  2. boundedElastic 有上限 (默认 10 × CPU 核数),打满后新请求会排队,超过 Schedulers.boundedElastic() 的队列上限(默认 100000)会抛 RejectedExecutionException。
  3. 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 服务端在生产上最常被问的问题,而框架没有给任何东西。

可做的补救(按成本从低到高):

  1. 在工具方法上加 @Observed (Spring Boot 的 AOP 观测,observationRegistry 自动接入)。成本最低,粒度是"每个工具一个 span"。

  2. 自定义 McpSyncServerCustomizer 包装 instructions 之类,无法插入 handler------它只能改 builder,不能包 handler ,所以这条路走不通(注意 McpSyncServerCustomizer.customize(SyncSpecification) 拿到的是 builder,不是已注册的 handler 列表)。

  3. 在 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 更划算。

  4. 在反向代理/网关层做统计 (按 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 本章小结

  1. 服务端有两条工具来源,且互不知情。 注解路径(McpServerSpecificationFactoryAutoConfiguration)与 ToolCallback 路径(ToolCallbackConverterAutoConfiguration)产出同类型 的 List<SyncToolSpecification> Bean,被 ObjectProvider + flatMap 直接拼接。去重只发生在 ToolCallback 侧(toMap 保首个),注解侧不去重,跨来源不校验。

  2. 注解扫描靠 BPP + AOT 两条腿。 BPP 用 findAnnotation(支持元注解)注册 Bean,provider 用 isAnnotationPresent(不 支持接口继承)------这个不对称是"Bean 注册成功但零工具"的根因。AOT 侧只注册 Bean 类本身与 MetaProvider,DTO 与配置类需要你自己补。

  3. @McpTool → SyncToolSpecification 的全部决策在 SyncMcpToolProvider:66-159 这 90 行里 :名字无格式化、描述无回退、title 三级回退、generateOutputSchema 默认 false、ReturnMode 先判 outputSchema 再判 void。ASYNC 版在 outputSchema 与错误文案上有实质差异(含一个 ClassCastException 缺口)。

  4. Schema 生成严重依赖 -parameters。 属性名与绑定 key 都来自 parameter.getName(),缺这个参数时 Schema 与绑定自洽但对模型不可读,且完全静默。

  5. 结果转换有六条分支 (CallToolResult 直通 / VOID→"Done" / STRUCTURED→JSON 往返 / null→"null" / String 直通 / 其他→JSON),Flux 被 .next() 截断成单值。

  6. 异常语义三类归宿 :McpError 与受检异常(UndeclaredThrowableException)硬抛到协议层,其他 RuntimeException 变成 isError=true 给模型看。这个契约与 Spring AI @Tool 对齐,但 ToolCallback 路径破坏 了它(一把 catch (Exception))。

  7. ToolCallback 路径是"够用但不完整":没有 title/hints/_meta/outputSchema,参数整包 JSON 传入,ASYNC 是伪异步(boundedElastic)。

  8. 服务端零观测、AOT 比客户端少一层。 两个空白都可以在应用层补,但框架没有给。


14.13 思考题

  1. 你把一个 @McpTool 方法从 public String foo(String a) 改成 public Mono<String> foo(String a),同时把 spring.ai.mcp.server.type 从 SYNC 改成 ASYNC。启动时日志里没有 WARN,但 tools/list 里少了一个工具。请列出所有可能的原因,并给出逐个排查的命令。

  2. ToolCallbackConverterAutoConfiguration 里那个私有的 isMcpToolProvider(:110-113)逻辑是取反 的,且没有任何调用点。如果有人把它"修复"成不取反并接进 aggregateToolCallbacks,系统会出现什么行为变化?(提示:想想暴露下游 MCP 工具的场景。)

  3. 一个 @McpTool 方法签名为 public CallToolResult foo(@McpToolParam String a),且注解上写了 generateOutputSchema = true。它的 ReturnMode 是什么?outputSchema 会不会生成?为什么这样设计是自洽的?

  4. 你的工具抛了一个 McpError,客户端收到的是"工具返回了错误文本"而不是"协议错误"。列出三种可能导致这个现象的配置/代码组合。

  5. 生产环境要求"每个 MCP 工具的调用次数、P99 耗时、错误率"可观测。基于 14.10 的分析,给出两套方案(成本最低的与粒度最细的),并说明各自会引入什么副作用。

  6. 如果把 spring.ai.mcp.server.expose-mcp-client-tools 设为 true,同时在同一个应用里既做 MCP 服务端又做 MCP 客户端,且下游服务端的工具名与本地 @McpTool 的工具名相同------会发生什么?结合 14.9 与第 13 章 13.5 回答。

  7. 为什么 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.java
  • annotation/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.factories
  • mcp/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 传输与状态管理。

相关推荐
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 23 章 模型供应商实现:从 `OpenAiSetup` 到 Bedrock
java·人工智能·开源
鶴哥只手遮天1 小时前
Spring AI 2.0.1 深度解析-第 06 章 内置 Advisor 全景:13 个实现逐个剖到源码级
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 03 章 消息体系与 Prompt:不可变模型、`ChatOptions` 与模板渲染
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 24 章 生产实践与 2.x 迁移:陷阱清单、AOT 与调优
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 10 章 工具调用(中):执行链路、结果转换与工具限制
java·人工智能·开源
鶴哥只手遮天2 小时前
Spring AI 2.0.1 深度解析-第 13 章 MCP 客户端:传输、工具聚合与命名冲突
java·人工智能·开源