上个月我把公司两个内部查询接口改造成 MCP 工具时,无意间发现 Spring AI 的@Tool注解方法里藏着一个“看不见”的参数类型:它不会出现在大模型能看到的参数列表里,却能在工具执行时把会话 ID、请求 ID、甚至网关透传的业务标识一并塞进来。当时我对着方法签名愣了半天,调完第一版工具后又被参数名丢失、类型推断错误折腾了两个晚上,才有了这篇笔记。这篇文章就围绕 Spring AI 中 MCP 注解相关的“特殊参数”展开:ToolContext 的注入机制、McpToolUtils 的传输通道、@ToolParam 的声明细节、参数类型推断的边界,以及我在生产环境里踩过的注解与参数坑。无论你是刚接触 Spring AI 的初学者,还是已经在做 MCP Server 的开发者,照着这篇笔记排查,能少走不少弯路。
1. 工具方法签名里的“隐形参数”:ToolContext 的注入机制
1.1 模型视角与开发者视角的参数差异
先看一个最典型的工具方法定义:
@Service public class WeatherTools { @Tool(description = "根据城市名称查询当前天气") public String getWeather( @ToolParam(description = "城市名称,例如:上海、北京") String city, ToolContext toolContext) { String sessionId = toolContext.getSessionId(); String requestId = toolContext.getRequestId(); return weatherService.query(city, sessionId, requestId); } }这里有个非常关键的设计差异:大模型在执行这个工具时,只会看到city这个参数。ToolContext 类型的参数会被 Spring AI 的MethodToolCallback自动识别并过滤掉,不会进 JSON Schema,不会出现在模型的调用参数里,模型也永远不会“主动传”这个值。它是开发者视角的运行时上下文,是框架在工具方法被调用的那一刻替我们注入进去的。
为什么需要这种设计?你可以把工具方法想象成餐厅服务员。服务员手里需要知道这桌客人坐在哪、点了什么菜(对应 sessionId、requestId),但顾客(模型)在点菜时只需要说“我要一碗牛肉面”,把“餐具编号”暴露给顾客没有任何意义。如果你硬要模型传 sessionId,模型既不知道怎么生成,又容易编造一个假值,工具内部拿到的还是脏数据。ToolContext 就是服务员手里的托盘,框架帮你把一次性餐具放好,你直接用即可。
1.2 ToolContext 的装载时机与生命周期
我最初以为 ToolContext 是在工具注册时创建的,后来翻实现才明白:它是在每次工具调用的执行链路中动态装配的。Spring AI 在把模型返回的 ToolCall 解析出来后,会构建一个ToolExecutionRequest,然后把当前请求链路中的会话信息、请求信息提取出来,封装成ToolContext,再通过反射调用你的@Tool方法。
生命周期也很明确:单次工具调用内有效。它不是长驻内存的全局对象,不会跨工具调用共享。也就是说,你在 A 工具里往 toolContext 塞了一个值,下一次调用 B 工具时,B 工具拿到的是一个新的 ToolContext,里面没有你塞的值。这个特性在我刚开始写审计工具时让我栽了一个跟头,后面第 5 章会展开讲。
如果你需要跨请求传递数据,正确的做法是:
- 把数据放进会话存储(如 ChatMemory);
- 在每次调用前,显式地组装 ToolContext;
- 或者在 MCP 客户端发起调用时,通过请求参数把业务标识带进来。
1.3 三个开箱即用的快捷读取方法
ToolContext 本身封装得很薄,常用读取方式有三种:
// 方式一:直接拿整个上下文 Map Map<String, Object> ctxMap = toolContext.getContext(); // 方式二:快捷读取会话 ID String sessionId = toolContext.getSessionId(); // 方式三:快捷读取请求 ID String requestId = toolContext.getRequestId();getContext()返回的是一个Map<String, Object>,你可以理解成一个键值对袋子。你自定义的键、框架内置的键都在这一个袋子里。getSessionId()和getRequestId()只是对这个袋子做了便捷封装,底层就是取McpToolUtils.SESSION_ID_KEY和McpToolUtils.REQUEST_ID_KEY对应的值。
我在实际项目里的用法是:工具方法里不直接依赖外部 ThreadLocal,而是统一从 ToolContext 读取用户标识,日志里把 sessionId 带出来,排查问题时能精确到“哪一次会话里的哪一次请求”。这在多人共享同一个服务端时尤其重要,不然日志里全是并发请求,根本无法追踪谁调了工具。
2. McpToolUtils 常量:MCP 会话元数据的传输通道
2.1 SESSION_ID_KEY 与 REQUEST_ID_KEY 的典型用途
在 Spring AI 的org.springframework.ai.model.tool包下,有一个工具类McpToolUtils。它的作用很纯粹:定义 MCP 上下文相关的常量,并提供把 ToolContext 合并进工具参数的静态方法。常用常量如下:
| 常量名 | 键值 | 典型用途 |
|---|---|---|
| SESSION_ID_KEY | sessionId | 多轮会话追踪、会话级缓存 |
| REQUEST_ID_KEY | requestId | 全链路日志追踪、幂等控制 |
| RESPONSE_SCHEMA_KEY | responseSchema | 声明返回结构,帮助模型理解输出 |
| FUNCTION_CALLING_CONTEXT_KEY | functionCallingContext | 函数调用上下文扩展 |
我们最常用的是前两个。当 Spring AI 作为 MCP Server 对外提供工具时,MCP 客户端发来的元数据会被映射成 ToolContext 里的键值对。也就是说,你在工具方法里读到的 sessionId,本质上是 MCP 协议请求带过来的会话标识,而不是你本地自己造出来的。
一个实际的审计场景:
@Tool(description = "获取订单详情") public String getOrderDetail( @ToolParam(description = "订单ID") String orderId, ToolContext toolContext) { String sessionId = (String) toolContext.getContext().get(McpToolUtils.SESSION_ID_KEY); String requestId = (String) toolContext.getContext().get(McpToolUtils.REQUEST_ID_KEY); auditLogService.record("ORDER_QUERY", orderId, sessionId, requestId); return orderService.findDetailJson(orderId); }这样每次工具调用都有完整的审计链路:哪个会话、哪个请求、查了哪个订单。对于企业内部敏感数据的调用,这几乎是标配。
2.2 mergeMcpContext 如何把上下文塞进工具调用
如果你研究过 Spring AI 的 MCP 适配层,会发现一个方法签名:McpToolUtils.mergeMcpContext(ToolContext toolContext, Map<String, Object> toolArguments)。
这个方法做的事情不多,但很关键:把 ToolContext 里的上下文键值对,合并进最终要传给 MCP 工具的参数 Map 中。默认是putIfAbsent逻辑,也就是业务参数优先,上下文只在业务参数没传对应键时才补进去。
我最初不理解为什么工具方法明明可以自动接收 ToolContext,还要多此一举做“合并”。后来翻文档才明白:ToolContext 的注入是 Spring AI 本地调用链路的特性;但 MCP 协议层是跨进程的,远端调用方只能看到一个扁平的 arguments 对象。如果不把会话元数据合并进去,远端工具就不知道这次调用属于哪个会话。所以这层合并,是本地上下文与远端协议参数之间的桥梁。
写代码时我的建议是两条路径分开处理:
- 工具方法内部优先读 ToolContext,拿不到再回退到参数;
- 如果是纯 MCP Server 场景,把关键上下文用
mergeMcpContext合并后统一传给底层服务。
2.3 自定义上下文键的写入与读取
除了框架内置的键,ToolContext 也支持自定义键。我在网关层接了用户中心的标识,把userId和tenantId放进上下文,工具方法里直接读取:
// 写入 toolContext.getContext().put("userId", "U00123"); toolContext.getContext().put("tenantId", "T888"); // 读取 String userId = (String) toolContext.getContext().get("userId"); String tenantId = (String) toolContext.getContext().get("tenantId");但这里有个坑:ToolContext 默认是不可变的,直接put在某些版本里会抛异常。更稳妥的做法是用ToolContext.updateToolContext或者通过ToolContext.builder()重新构建。我建议你在动手前先看一眼自己项目里 Spring AI 的版本,确认 ToolContext 的 setter 是否开放。
还有一个小建议:自定义键的命名别太随意。键名是裸字符串,没有命名空间,万一和框架内置键撞了,排查起来极其痛苦。我一般用带前缀的名字,比如x-user-id、x-tenant-id,这样和协议的 Header 命名风格统一,也更容易辨识。
3. @ToolParam 的参数声明细节:从命名、必填到类型推断
3.1 name 与 description 的作用
@ToolParam是修饰方法参数的注解,它有两个高频属性:
@ToolParam(name = "orderNo", description = "订单编号,必填", required = true) String orderNoname决定这个参数在 JSON Schema 里的字段名。如果不指定,Spring AI 会尝试用 Java 编译时的参数名。这里就隐藏着一个大坑:如果项目没有开启-parameters编译参数,反射拿到的参数名会是arg0、arg1这种鬼名字,模型看到 schema 里满屏的 arg0、arg1,基本等于猜谜。后面第 5 章我会专门讲这个。
description是写给模型看的说明。不要小看这段文字,它直接决定模型能不能正确填参数。我见过一个天气工具,参数描述写的是“城市”,模型就敢传“上海天气”四个字进去;把描述改成“城市名称,例如:上海、北京,不要包含‘天气’字样”后,调用准确率立刻上来了。描述写得越具体、越带示例,模型的表现就越好。
3.2 required 语义在 MCP 端点中的体现
required默认是true,也就是所有参数默认必填。这个语义在 JSON Schema 里体现得很直接:必填参数会出现在 schema 的required数组中;可选参数则不会。
但我实测下来,required = false有一个容易被忽略的副作用:模型不传这个参数时,你的 Java 方法参数会是null,而不是某个默认值。也就是说,Spring AI 不会帮你做“参数缺省”的填充。你在方法体里必须自己判空、给默认值。我常用的写法:
@Tool(description = "根据条件查询订单列表") public String listOrders( @ToolParam(description = "订单状态", required = false) String status, @ToolParam(description = "每页条数,默认20", required = false) Integer pageSize) { int size = (pageSize == null) ? 20 : pageSize; String effectiveStatus = (status == null || status.isBlank()) ? "ALL" : status; // ... }还有一个细节:可选参数尽量放在方法签名靠后的位置。虽然 Java 反射不在乎顺序,但模型在组装 JSON 时往往更习惯先填必填字段,你把可选参数塞前面,模型偶尔会把 JSON 字段顺序搞错,导致反序列化时字段错位。虽然框架最终按名字匹配,但玄学概率确实存在,尤其在你用了 Lombok 的@Builder时更容易出幺蛾子。
3.3 参数类型的 JSON Schema 推断规则
Spring AI 通过 Jackson 的类型推断机制,把 Java 方法参数转成 JSON Schema。常见的对应关系如下:
| Java 类型 | Schema 类型 | 实测说明 |
|---|---|---|
| String | string | 最稳定 |
| int / long / Integer | integer | 稳定 |
| double / float / BigDecimal | number | 稳定 |
| boolean | boolean | 稳定 |
| record | object | 字段取自 record 组件 |
| Java Bean | object | 依赖 getter 推断 |
| List<String> | array+items=string | 泛型可推断时较稳定 |
| Map<String, Object> | object | 偏“黑盒”,值类型推断丢失 |
| 枚举 | string | 部分版本会把枚举常量写进enum数组 |
最让我省心的是 record 类型。用一个 record 把多参数包起来,工具方法签名立刻整洁,且 JSON Schema 自动把 record 组件映射成对象字段:
public record OrderQuery( String orderNo, List<String> statusList) { } @Tool(description = "查询订单") public String queryOrder(OrderQuery query) { // query.orderNo() // query.statusList() }对应的 Schema 大致是:
{ "type": "object", "properties": { "orderNo": { "type": "string" }, "statusList": { "type": "array", "items": { "type": "string" } } }, "required": ["orderNo", "statusList"] }3.4 那些让类型推断翻车的场景
类型推断不是万能的,实际踩坑集中在三个地方。
第一个是泛型擦除。List<MyOrder>这种嵌套泛型,如果没有足够信息,Schema 可能只生成array而没有items的对象结构,模型不知道该往数组里塞什么。解决办法是抽成显式 record 或 DTO,把类型信息写死。
第二个是没有无参构造器的 Java Bean。Spring AI 做反序列化时,如果你用的是普通类而不是 record,框架需要调无参构造器再 set 字段。一旦你把构造器写成全参构造且没提供无参构造器,工具调用时大概率报反序列化异常。所以我的建议很直接:新代码一律用 record,别再用老式 POJO。
第三个是 Map<String, Object> 的值类型黑洞。Map 作为参数时,Schema 只能推断出“这是一个对象”,里面的 value 是什么类型,框架没法表达。结果就是模型乱填,工具收到后还要自己强转,类型对不上就抛异常。能用 record 解决的问题,别交给 Map。
4. 从普通工具到 MCP 端点:注解参数在协议层如何映射
4.1 本地 @Tool 与远程 MCP 工具的关系
在 Spring AI 里,同一个@Tool方法有两种用途:
- 作为本地函数调用工具,直接注册给 ChatModel;
- 作为 MCP Server 的工具,通过协议暴露给远程客户端调用。
你不需要为两种场景写两套代码。配置层面,开启 MCP Server 的开关即可:
spring: ai: mcp: server: name: my-business-tools enabled: true transport: stdiotransport可以是stdio,也可以是 HTTP。stdio 模式适合本地进程对接,比如桌面客户端拉起一个子进程;HTTP 模式适合跨机器调用。你的@Tool方法会被 MCP Server 适配层扫描,生成 MCP 协议里的工具定义(name、description、inputSchema)。注解上的描述信息会原封不动地变成协议里的描述字段。
顺带说一句:如果项目里已经有 REST 接口,想快速转成 MCP 工具,没必要重写逻辑。把 Controller 里调用的 service 方法提取成工具方法,加上@Tool注解,复用原参数结构,底层的业务逻辑保持不变。我做过一个订单查询 REST 接口转 MCP 的改造,核心改动就是把 service 层方法加上@Tool和@ToolParam描述,REST 层继续保留给网页端用,一套逻辑两边吃。
4.2 输入 Schema 与 CallToolRequest 参数的对应
当 MCP 客户端调用一个工具时,会发送CallToolRequest,里面的arguments是一个 JSON 对象。Spring AI 在服务端收到后,会把这个 JSON 对象反序列化到你@Tool方法的参数上。映射规则就是第 3 章讲的 schema 规则。
几个容易踩的细节:
- 参数名(
name或 Java 参数名)就是 JSON 对象里的 key。比如方法参数String city,客户端就得传{ "city": "上海" },传成{ "cityName": "上海" }必然报错。 - 如果参数是 record,客户端要传嵌套对象:
{ "query": { "orderNo": "123", "statusList": ["PAID"] } }。这时候方法签名建议只保留一个 record 参数,不要混着传多个对象参数,否则 JSON 结构很难看,模型也容易绕晕。 - 客户端如果多传了 schema 里没有的字段,默认情况下 Jackson 会忽略掉,不会报错。这算是容错,但也可能掩盖问题,比如字段名拼错导致数据库查询用了空值。
我在调试期喜欢在工具方法第一行打印收到的参数,配合 MCP Inspector 查看实际传入的 JSON,一对比就能看出模型传参和期望之间的偏差。
4.3 使用MCP工具流式输出内容到文件的场景
前面讲的都是返回一个字符串给模型,但还有一种常见需求:让 MCP 工具把内容写入本地文件。这个操作如果交给模型自己干,模型既不知道文件路径,也没有文件系统访问权限,所以最好做成工具。
我写过一个写文件工具,核心逻辑是:接收文件名和内容,把内容写到临时文件,再原子替换成目标文件。这样即使内容写了 50MB 写到一半进程挂掉,也不会产生半截文件。
@Tool(description = "把内容写入指定路径的文件,返回写入结果") public String writeContentToFile( @ToolParam(description = "目标文件路径,例如 /data/output/report.txt") String filePath, @ToolParam(description = "要写入的文本内容") String content) { File target = new File(filePath); File parent = target.getParentFile(); if (parent != null && !parent.exists()) { parent.mkdirs(); } File tmp = new File(target.getAbsolutePath() + ".tmp"); try (FileWriter writer = new FileWriter(tmp, StandardCharsets.UTF_8)) { writer.write(content); writer.flush(); if (!tmp.renameTo(target)) { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); } return "写入成功:" + target.getAbsolutePath() + ",字节数:" + content.getBytes(StandardCharsets.UTF_8).length; } catch (IOException e) { return "写入失败:" + e.getMessage(); } }这类工具接入到 MCP 后,配合 OpenAPI/API 网关调用,可以实现“让 AI 把流式输出落到磁盘”的自动化流程,比如把模型生成的完整报告、代码片段直接写到工作目录。注意一点:工具返回的字符串不要太长,MCP 协议对单次响应有大小限制,大文件写入别把整个内容拼进返回值里,返回文件路径和摘要即可。
4.4 一个可复现的完整 Demo:文件写入工具 + 审计工具
把前面的知识串起来,我贴一个最小可复现的工程骨架。依赖用 Spring AI MCP Server Boot Starter,核心代码就两个工具类。
@Component public class FileTools { @Tool(description = "把内容写入指定路径的文件,返回写入结果") public String writeContentToFile( @ToolParam(description = "目标文件路径") String filePath, @ToolParam(description = "要写入的文本内容") String content) { // 实现见 4.3 节 return "..."; } } @Component public class AuditTools { @Tool(description = "查询当前会话的审计信息") public String currentAuditInfo(ToolContext toolContext) { String sessionId = toolContext.getSessionId(); String requestId = toolContext.getRequestId(); Map<String, Object> ctx = toolContext.getContext(); return "sessionId=" + sessionId + ", requestId=" + requestId + ", extra=" + ctx; } }第二个工具类里,ToolContext就是一个“特殊参数”:模型看不到,但工具内部能拿到会话元数据,非常直观地演示了本文的主题。启动应用后,用 MCP Inspector 或任意 MCP 客户端连接这个 server,看工具列表时,你会发现currentAuditInfo的 inputSchema 为空对象,模型确实不需要传任何参数。
5. 我在生产环境里踩过的注解与参数坑
5.1 子类重写方法后 @Tool 注解为什么会丢
这个坑我在一个基础工具类的继承场景里踩得特别惨。父类定义了一个getServerTime()工具方法,子类为了扩展返回值,重写了这个方法,但没加@Tool注解。结果工具列表里getServerTime要么消失,要么行为变成了子类的新实现但描述还是父类的描述,各种诡异。
原因是 Java 反射的注解可见性规则:@Tool的注解保留策略是 RUNTIME,但当你调用subClass.getMethod("getServerTime")时,反射拿到的是子类版本的Method,而子类方法上没有它自己的@Tool注解。Spring AI 的扫描逻辑默认不会跨层级去父类找注解,于是这个工具就“丢”了。
解决办法有两条:
- 子类重写方法时,重新标注
@Tool,描述也重新写一份; - 如果你确定所有子类共用父类实现,干脆不重写,只做扩展方法。
日常排查时记住一点:注解这个东西,默认只认本类,不认继承。遇到工具行为异常,先看被调到的类是哪个,再看注解贴在哪一层。
5.2 参数名丢失:从 arg0 说起
前面提过,没有-parameters编译参数时,反射拿到的参数名是arg0、arg1。模型看到的工具参数列表就成了:
{ "arg0": { "type": "string" }, "arg1": { "type": "string" } }大模型看到这种 schema,根本不知道该填什么。它可能会猜“arg0 应该是城市”,也可能直接拒绝调用,现象就是模型不断说“工具参数不明确,我需要更多信息”,或者干脆报参数错误。
排查方法很简单:看看生成的 schema 里参数名是不是读成了 argX。如果是,那就是编译参数没开。Maven 配置加上:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <parameters>true</parameters> </configuration> </plugin>加了之后重新编译部署,参数名立刻恢复正常。注意这个配置对模块化多工程项目要逐个检查,经常出现主模块开了、某个公共模块没开,结果那个模块里暴露的工具全是 arg0。
5.3 工具名冲突与重载方法问题
@Tool注解默认用方法名作为工具名。如果你在同一个类里写了重载方法:
@Tool(description = "查询订单") public String queryOrder(String orderNo) { ... } @Tool(description = "按状态查询订单") public String queryOrder(String status, int page) { ... }工具名都是queryOrder,MCP Server 注册时直接冲突,启动报错。别问我怎么知道的,这种错误在大型工具类里特别隐蔽,因为两个方法签名差异很大,编译器完全不会报错。
解决方案很简单:用注解的name属性显式指定不同工具名:
@Tool(name = "queryOrderByNo", description = "按订单号查询订单") public String queryOrder(String orderNo) { ... } @Tool(name = "queryOrderByStatus", description = "按状态查询订单") public String queryOrder(String status, int page) { ... }这也给了一个启发:工具名本身是给模型看的,一个清晰的名字比一大段 description 还管用。
5.4 一次排查链路实录:从“工具调不通”到定位类型推断
最后分享一个完整的排查过程,用来演示工具参数的排错思路。
现象:模型调用了一个名为listOrders的工具,一直报“传入参数无法解析”。我第一反应是模型传参不对,但连续几次失败后我决定看一眼服务端的工具 Schema,发现问题出在参数List<OrderSummary> orders上——生成的 Schema 里items是个空对象,模型只知道要传数组,不知道数组元素里该有哪些字段。
排查链路如下:
第一步,打印 MCP Server 启动时生成的工具定义,确认listOrders的 inputSchema。这一步我直接在工具注册处加了个日志输出,落盘之后用格式化工具查看。
第二步,对比 schema 和 Java 类型定义,发现OrderSummary是普通内部类,字段是包私有的,没有 public getter。Jackson 的自动类型推断对这类类只会生成一个空对象结构。
第三步,把OrderSummary改成 public record,字段改为 public 组件。重启服务,schema 立刻生成了完整的字段结构,模型传参恢复正常。
整个排查花了一个多小时,但核心就三件事:看 schema 是否正确,看类型结构是否被 Jackson 正确识别,看字段可见性是否足够。我现在的习惯是每新增一个工具,先启动应用到 MCP Inspector 里看一眼 schema,再让模型调用。schema 对了,90% 的工具调用问题都不会发生。
说回 ToolContext 的跨调用陷阱,我最初以为可以把用户标识塞进 context 留着下次用,结果发现每次工具调用都是新的 ToolContext,白折腾了一晚上。后来老老实实在 MCP 请求参数里带上业务标识,才彻底解决。
写工具方法这几年,我最大的体会是:MCP 注解和特殊参数这套机制,本质上是在“让模型好调用”和“让开发者好维护”之间找平衡。ToolContext 把运行时信息从业务参数里剥离出来,让模型看到的签名足够简单;@ToolParam 的 description 写得到位,模型就能减少 80% 的瞎填参数;类型推断虽然框架做了很多,但最终兜底的还是我们自己选择的参数结构。最后再分享一个小技巧:每个新增的工具方法,先在本地跑通一次模型调用再上线,跑不通就打印 schema 看结构,别直接丢给 MCP 客户端去试——客户端在协议层面的报错,远没有服务端日志来得直观。