
上个月我把公司两个内部查询接口改造成 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 MapString, Object ctxMap toolContext.getContext(); // 方式二快捷读取会话 ID String sessionId toolContext.getSessionId(); // 方式三快捷读取请求 ID String requestId toolContext.getRequestId();getContext()返回的是一个MapString, 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_KEYsessionId多轮会话追踪、会话级缓存REQUEST_ID_KEYrequestId全链路日志追踪、幂等控制RESPONSE_SCHEMA_KEYresponseSchema声明返回结构帮助模型理解输出FUNCTION_CALLING_CONTEXT_KEYfunctionCallingContext函数调用上下文扩展我们最常用的是前两个。当 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, MapString, 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 类型实测说明Stringstring最稳定int / long / Integerinteger稳定double / float / BigDecimalnumber稳定booleanboolean稳定recordobject字段取自 record 组件Java Beanobject依赖 getter 推断ListStringarrayitemsstring泛型可推断时较稳定MapString, Objectobject偏“黑盒”值类型推断丢失枚举string部分版本会把枚举常量写进enum数组最让我省心的是 record 类型。用一个 record 把多参数包起来工具方法签名立刻整洁且 JSON Schema 自动把 record 组件映射成对象字段public record OrderQuery( String orderNo, ListString 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 那些让类型推断翻车的场景类型推断不是万能的实际踩坑集中在三个地方。第一个是泛型擦除。ListMyOrder这种嵌套泛型如果没有足够信息Schema 可能只生成array而没有items的对象结构模型不知道该往数组里塞什么。解决办法是抽成显式 record 或 DTO把类型信息写死。第二个是没有无参构造器的 Java Bean。Spring AI 做反序列化时如果你用的是普通类而不是 record框架需要调无参构造器再 set 字段。一旦你把构造器写成全参构造且没提供无参构造器工具调用时大概率报反序列化异常。所以我的建议很直接新代码一律用 record别再用老式 POJO。第三个是 MapString, 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(); MapString, 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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin加了之后重新编译部署参数名立刻恢复正常。注意这个配置对模块化多工程项目要逐个检查经常出现主模块开了、某个公共模块没开结果那个模块里暴露的工具全是 arg0。5.3 工具名冲突与重载方法问题Tool注解默认用方法名作为工具名。如果你在同一个类里写了重载方法Tool(description 查询订单) public String queryOrder(String orderNo) { ... } Tool(description 按状态查询订单) public String queryOrder(String status, int page) { ... }工具名都是queryOrderMCP 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发现问题出在参数ListOrderSummary 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 客户端去试——客户端在协议层面的报错远没有服务端日志来得直观。