MCP 三层校验,修好 SecurityContext 丢失

排查顺序只有一条:参数是否合法 → 身份是否存在 → 权限是否允许 → 风险动作是否已经确认。对应到实现上,就是输入校验、安全授权、执行确认三道闸门;SecurityContext 丢失属于第二道闸门之前没有取得可信身份的问题。

遇到 SecurityContext Not Propagated When Calling Tools in Spring AI MCP Server,应先核对实际依赖版本、MCP Server 是同进程还是远程、Servlet 或 Reactive Transport,以及工具执行前后的线程和 Principal。不要让模型在提示词里补传 userId、tenantId:模型参数可以作为业务输入,但不能成为身份凭证。入口没有 Principal 是认证链问题;入口有、工具执行前消失,才继续查上下文传播;Principal 一直存在,则应转查角色、租户、资源权限和业务状态。

先把失败位置固定下来

AI Agent 调用 MCP 工具时,建议把一次请求的执行过程固定为下面的链路:

ToolCall → ArgsValidator → PrincipalResolver → PolicyEngine → ApprovalGate → ToolExecutor → Audit

入口生成或接收一个可信的 correlationId,并让它贯穿参数校验、授权、确认页面、最终执行和审计日志。这样看到“工具调用失败”时,可以先回答它停在哪一步,而不是从模型输出或异常栈里猜。

位置 固定失败类别 必须停止的动作
ArgsValidator ARGUMENT_INVALID 不进入业务方法
PrincipalResolver IDENTITY_MISSING 不使用模型参数补身份
PolicyEngine AUTHORIZATION_DENIED 不准备或提交高风险操作
ApprovalGate APPROVAL_REQUIRED 不调用执行器
ToolExecutor 成功或受控的业务失败 记录结果和副作用

确认过期、被取消、参数摘要不一致、重复提交,可以作为 APPROVAL_REQUIRED 下的稳定原因码,不必让模型理解内部状态机。真正未预期的空指针、连接失败等则单独记录为平台异常,不能伪装成“参数错误”。

三道闸门与四个排查问题并不矛盾:身份是否存在是安全授权的输入条件,身份和权限合并后就是第二层。审计则不是最后补一条日志,而是每次尝试都要经过的横切流程。把 MCP Server 作为参数、身份、权限和审计的集中执行点,也是常见且合理的职责划分,但具体实现仍要服从所用 Spring AI 版本的接口和传输方式。

参数先过结构校验

工具描述和 JSON Schema 主要帮助模型生成参数,不是服务端安全边界。原始 arguments 应先经过严格反序列化,再变成工具自己的 DTO。严格模式至少要拒绝未知字段、JSON 类型错误和容易产生歧义的宽松转换,不能让 "100"、"" 或 null 在后续业务代码里悄悄变成金额、账号或时间。

下面是与 Spring AI 具体 starter 解耦的伪代码,方法名不是框架 API:

record TransferArgs(
    String orderId,
    String payeeAccountId,
    BigDecimal amount,
    String currency,
    Instant requestedAt
) {}

record FieldError(
    String path,
    String code,
    String expected
) {}

record ValidationResult(
    TransferArgs value,
    List<FieldError> errors
) {}

ValidationResult validate(JsonNode rawArguments) {
    TransferArgs args = strictMapper.convert(rawArguments);
    List<FieldError> errors = shapeValidator.validate(args);
    errors.addAll(crossFieldValidator.validate(args));

    if (!errors.isEmpty()) {
        return new ValidationResult(null, List.copyOf(errors));
    }
    return new ValidationResult(args, List.of());
}

这里的 strictMapper 要明确配置两件事:未知字段失败,敏感字段的有损类型转换失败。shapeValidator 再检查必填字段、null、空字符串、枚举、格式、数值范围和长度。金额不能只检查“是不是数字”,还要检查精度、币种和业务上下限;具体上限来自业务规则,不应写死在通用 MCP 适配层。

时间参数也要先确定口径。跨系统操作最好要求带偏移量或明确时区,并规范化成统一时间类型;如果业务确实只有本地日期,就把时区作为显式参数传入。不要根据服务器默认时区猜“昨天”或“当天截止”。

DTO 的跨字段校验完成后,才适合进入带数据库或 Principal 的业务预检查。例如参数中的金额、账户和币种彼此没有冲突,并不代表订单仍处于可支付状态、收款账户仍属于当前租户。需要查询业务数据或取得用户身份的规则,应放到身份解析和权限策略之后,不能为了“提前校验”把可信身份塞进模型参数。

敏感参数尤其不能静默补默认值。缺少账户、权限或确认信息时,失败比自动填一个“看起来合理”的值安全得多。

校验错误要让模型能纠正

参数校验失败不应变成一串异常栈,也不应只返回“参数错误”。模型要修正参数,至少需要稳定的错误类别、字段路径、期望类型,以及是否可以重试。可以把应用层错误对象设计为:

{
  "correlationId": "<trace-id>",
  "code": "ARGUMENT_FIELD_INVALID",
  "field": "arguments.amount",
  "expected": "decimal greater than 0",
  "retryable": true
}

这是应用层载荷示例,不代表 Spring AI 或 MCP SDK 会原样发送。外层究竟包装成协议错误还是工具执行结果,要按项目实际使用的协议和 SDK 版本验证;内部错误对象可以保持一致。

字段路径应稳定,例如始终使用 arguments.amount,不要一次返回数据库列名、一次返回 Java 字段名。expected 只描述可修正的信息,不泄露表结构、内部策略或敏感数据。金额、账号、手机号和消息正文不要原样回显到错误信息里。

retryable 也不应被理解成“无限重试”。缺字段可能允许模型根据用户原话补齐,但缺少授权、确认或敏感业务值时,模型没有权限自行决定。应用应限制自动纠正次数;达到边界后返回人工处理,而不是让 Agent 反复猜参数。可预期的参数拒绝也不应被包装成未处理异常,最后只留下通用 500。

SecurityContext Not Propagated When Calling Tools in Spring AI MCP Server

这个搜索词对应的故障原因不止配置错误。排查的第一步是确认 MCP Server 与 Agent 应用是不是同一个进程。如果是远程 MCP Server,网络边界不会自动传递原进程的 SecurityContextHolder,服务端必须收到并验证独立的调用凭证;这时复制 ThreadLocal 没有意义。

同进程场景再继续看实际依赖和 Transport。资料显示,Spring AI 1.0.x 的一个 Servlet MCP Server 存在一个已知问题:McpSyncServer 内部委托给 McpAsyncServer,工具最终运行在 Reactor 的 boundedElastic 线程上。由于 Spring Security 的 SecurityContextHolder 使用 ThreadLocal,切换线程后原上下文随之丢失。资料称相关修复进入 1.1.0-M1,给出的处理路径是升级到 1.1.0+;这一结论只适用于它描述的版本和执行方式,不能外推到所有 Transport。

如果没有立即升级,资料还提到可以评估 WebFlux Transport,并显式捕获 Reactor Context。这个方向不能只靠复制一段 ThreadLocal 代码:WebFlux 与 Servlet 的执行模型不同,Reactor Context、ThreadLocal、事务、阻塞调用和异常处理都要重新验证。尤其不要把网上不完整的上下文捕获片段直接用于生产。

定位时不要只看最终异常,还要在入口和工具执行前分别记录同一 correlationId 下的 Transport、阶段、Principal 是否存在和当前线程名。诊断日志只记录布尔状态和内部追踪字段,不记录令牌、完整 Principal 或业务参数。

根据这些记录判断:入口和工具执行前都没有 Principal,优先检查认证过滤器和凭证传递;入口存在、工具前消失且线程发生变化,才符合上下文传播问题的特征;两处 Principal 都存在,说明不是传播问题,应继续检查权限和业务状态;远程调用只有服务账号,也没有可验证的最终用户身份,则高风险工具不应执行。

版本号、starter 名称和 API 形态应以项目实际依赖及 Spring AI 官方发布说明为准。二手集成文章本身也提醒,相关版本和接口需要回到官方文档核验。

身份存在后再判权限

PrincipalResolver 的结果必须来自受信任的认证上下文,例如已经验证的令牌、mTLS 身份或经过校验网关注入的身份信息。模型生成的 userId、tenantId 最多只能当作业务提示或一致性检查,不能充当授权依据。尤其要区分服务账号和最终用户:系统只有服务账号,不代表当前请求里的用户已经通过认证。

取得 Principal 后,授权至少要覆盖角色、租户、资源和动作类型。角色决定能不能使用某类工具,租户决定能访问哪片数据,资源所有权决定能不能操作这个订单或账户,动作类型则区分查询、修改、删除和付款。模型在前端看不到按钮,只代表界面没有提供入口;工具描述里写了“仅管理员”,也不等于服务端已经完成授权。

默认策略应拒绝未明确允许的操作。MCP Server 层适合集中执行权限、审计和限流,但不能只做工具级检查而忽略业务接口原有的对象级权限。两层规则不一致时,应复用同一份授权语义,或在执行前再次调用权威业务策略。

授权日志应记录拒绝原因和策略结果,例如角色不足、租户不匹配、资源不存在或状态不允许,但不要为了排障打印完整账号、令牌和订单内容。资源不存在与无权访问是否返回同样的外部信息,也应按系统的防枚举策略决定。

高风险动作先准备再提交

只读工具通过参数和授权检查后可以直接执行。写库、删除、付款、发消息这类高风险工具,则应拆成无副作用的 prepare 和受确认保护的 commit。

prepare 完成参数校验、身份解析、授权和业务预检查,返回由服务端生成的预览,例如目标对象、实际金额、币种、影响范围和过期时间。它不能只返回一段模型生成的摘要,因为模型可能遗漏关键字段或改变表述。用户看到的确认内容应来自服务端已经校验过的规范化参数。

确认请求必须来自可信客户端或用户界面,并通过当前认证会话提交。仅在模型输出里出现“我确认”,或者让模型调用一个通用的 approveTool(approvalId),都不能构成人工确认。若确认页面使用 Cookie 会话,还应按 Web 应用的认证方式处理 CSRF,不能因为接口是给 Agent 调用的就放松浏览器侧校验。

服务端可以保存这样的确认记录:

record ApprovalTicket(
    String approvalId,
    String principalId,
    String tenantId,
    String sessionId,
    String toolName,
    String normalizedArgsHash,
    Instant expiresAt,
    ApprovalStatus status
) {}

normalizedArgsHash 应由服务端对规范化参数计算,例如统一 JSON 字段顺序、金额表示、枚举值和时间口径。哈希只是绑定关系的一部分,不能单独承担安全职责;服务端还要保存票据状态,并使用不可预测的一次性标识。客户端提交的摘要不能替代服务端重新计算。

票据至少绑定用户、租户、会话、工具名、规范化参数摘要和过期时间。模型可以在 prepare 后请求展示确认,但不能自行把票据改成已确认,也不能替另一个用户确认。

确认后执行前重新校验

commit 收到票据后不能直接调用工具。执行前需要重新检查当前认证状态,授权是否仍允许,票据是否属于当前用户和会话,工具名是否一致,参数摘要是否一致,票据是否过期或已取消,以及业务资源状态是否发生变化。

票据状态和幂等占位应当原子更新,避免两个并发 commit 同时看到“已确认”。客户端超时后重试时,应返回第一次执行的结果或明确的处理中状态,不能再次写库、再次付款或再次发送消息。

本地锁或数据库唯一约束不能自动让外部副作用变成 exactly-once。支付、消息和第三方 API 如果支持幂等键,应把同一个业务幂等键继续传下去;不支持时还要设计查询和补偿流程。这里能承诺的是阻止重复提交,不能在没有下游配合的情况下承诺绝对只执行一次。

参数在确认后被修改,应拒绝使用旧票据并要求重新准备。用户在有效期内取消、权限被回收、账户状态改变或订单已经处理,也都应停止执行。票据过期后不能由模型“续期”,应由服务端重新评估并创建新的准备结果。

如果 prepare 本身创建了库存占用、草稿或冻结记录,它就已经不是无副作用操作。那类状态要有独立生命周期、过期清理和审计,不能因为名字叫“准备”就把它当成只读。

用最小用例验收

链路验收不应只测“模型能不能选中工具”。至少要覆盖下面的分支,并确认失败路径没有调用业务执行器:

场景 预期结果 是否允许副作用
缺少字段、值为 null 或空字符串 ARGUMENT_INVALID 否
JSON 类型错误、未知字段、枚举或格式错误 ARGUMENT_INVALID 否
数值越界或跨字段规则冲突 ARGUMENT_INVALID 否
参数结构合法,但订单状态不允许操作 业务前置条件失败 否
调用方未认证 IDENTITY_MISSING 否
只有服务账号,没有最终用户身份 高风险操作拒绝 否
角色不足或跨租户访问 AUTHORIZATION_DENIED 否
只读工具且参数、身份、权限都合法 正常执行 只允许读取
写操作缺少确认票据 APPROVAL_REQUIRED 否
确认过期、取消或参数被修改 APPROVAL_REQUIRED 否
确认后权限发生变化 重新授权失败并停止 否
相同票据并发提交或客户端重试 最多一次业务副作用 不得重复写入

还要在项目实际使用的 Servlet 或 Reactive Transport 下运行这些用例,尤其重复验证 Principal 在工具入口是否存在。测试使用的 Spring AI 依赖必须与准备发布的依赖一致,否则“本地能过”不能说明构建产物也具备相同行为。

版本和日志都要可追溯

先查项目实际解析出的 Spring AI 依赖,不要只看 BOM 或属性文件里写了什么版本。按实际构建方式二选一运行,查看项目解析出的实际依赖,不要采用与项目无关的示例输出:

mvn dependency:tree -Dincludes=org.springframework.ai
./gradlew dependencyInsight --dependency spring-ai --configuration runtimeClasspath

同时确认实际启动的 MCP Server Transport。主应用使用 Servlet,并不必然代表独立 MCP Server 也使用相同传输栈;MCP Server 是远程进程时,还要沿调用链检查客户端凭证、服务端认证和最终用户身份是否被安全转发。

从旧版本升级时,重点回归安全行为和执行模型变化。以前可能悄悄使用服务账号或默认身份的工具,升级后可能开始明确返回身份缺失或权限拒绝;同步、异步执行假设变化后,也要检查事务、SecurityContext、MDC、链路追踪、超时和取消。只读工具、确认流程和远程调用都要单独回归,不能用“模型还能调用”代替验证。

审计日志至少记录实际版本与 Transport、correlationId、工具名、Principal 的内部标识、租户、参数错误类别与字段路径、授权结果、确认 ID 和状态、参数摘要、幂等处理结果、执行耗时及最终结果。令牌、完整账号、手机号、消息正文和其他敏感参数必须脱敏。

指标可以统计参数错误、身份缺失、权限拒绝、确认过期、重复提交和工具耗时,但不要把用户 ID、确认 ID 或参数值放进高基数指标标签。日志、指标和 Trace 是排障证据,不能反过来成为模型可以读取的授权或确认通道。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容