上一篇我们用 Spring AI 搭了一个 MCP Server。
加上 @Tool 注解,配好 YAML,工具就能被Agent发现并加载了。
但有一个问题没处理——MCP Server 没有加鉴权,任何 Agent 只要知道地址就能调用。

生产环境不可能这么干。
MCP Server 暴露的是真实业务能力,求职派的岗位推荐、数据采集、浏览器操作这些工具如果不加鉴权,等于把整个后端能力免费开放了。
鉴权的本质只有一件事,搞清楚“谁在调用”,然后决定让不让调。
这篇内容就从最简单的硬编码 token 讲起,再拆解到求职派的生产方案,Filter + 拦截器、Bearer token 与用户体系绑定、请求上下文跨线程传递。
01、Filter 所有请求
先看最基础的做法。
写一个 Servlet Filter,拦截 MCP 的 SSE 和消息端点,从请求头里提取 Authorization 字段做校验。
public static final String TOKEN = "my-secret-token";
String auth = request.getHeader("Authorization");
if (request.getRequestURI().equals("/sse")
|| request.getRequestURI().equals("/mcp/messages")) {
if (auth == null || !auth.equals("Bearer " + TOKEN)) {
throw new RuntimeException("认证失败");
}
}
判断请求路径是不是 MCP 端点,如果是就检查 Authorization 头,token 对了就放行,不对就拒绝。
@WebFilter 的 asyncSupported = true 必须加上,因为 MCP 的 SSE 通信是异步的,不开这个选项客户端就连不上。
MCP 客户端配置也只要多加一个 headers 字段就行。
{
"mcpServers": {
"时间MCP": {
"type": "sse",
"url": "http://localhost:8080/sse",
"headers": { "Authorization": "Bearer my-secret-token" }
}
}
}
下面是加鉴权前后的对比效果。

这个方案能用,但有三个明显的问题。

第一,token 是硬编码在代码里的,所有用户共享同一个 token,服务端没办法区分“谁在调用”。一百个 Agent 连上来,在服务端看起来都是同一个。
第二,没有用户上下文。MCP 工具执行时拿不到调用者的身份信息,岗位推荐工具不知道该给谁推荐,做不了任何个性化的事情。
第三,token 一旦泄露,除了改代码重新部署,没有别的办法让它失效。
02、Filter + 拦截器
既然一个 Filter 解决不了,那我们就要追加其他方案。

第一,加请求记录过滤器(app/.../web/hook/filter/ReqRecordFilter.java),拦截所有请求(不只是 MCP),负责初始化请求上下文、记录访问日志、处理跨域。
对于普通 Web 请求,还会从 Cookie 或 JWT 中解析用户身份并写入上下文。对于 MCP 请求,只做初始化,不做身份解析,因为 MCP 客户端不会带 Cookie。
第二,加权限校验拦截器(app/.../web/hook/interceptor/PermissionCheckInterceptor.java),只关心 /api/** 路径下的请求。
对于普通 Controller 方法,检查方法上的权限注解来判断是否放行,支持管理员、VIP、普通用户三种角色等级。
对于 MCP 端点,走一套独立的鉴权逻辑,从 Authorization 头里提取 Bearer token,去数据库查用户。
为什么要这样做?
因为 MCP 请求和普通 Web 请求的鉴权方式完全不同。
Web 用户靠 Cookie 里的 session token 登录,MCP 客户端靠 HTTP 请求头里的 Bearer token 认证。
过滤器统一做初始化,拦截器按类型分别处理,两条鉴权路径互不干扰。
MCP 鉴权的核心逻辑
权限校验拦截器里有一段专门处理 MCP 请求的方法(app/.../web/hook/interceptor/PermissionCheckInterceptor.java)。
String auth = request.getHeader("Authorization");
if (StringUtils.isBlank(auth) || !auth.startsWith("Bearer ")) {
return false;
}
String token = auth.substring(7);
UserBo user = SpringUtil.getBean(UserService.class).getUserByWxId(token);
if (user == null) { return false; }
ReqInfoContext.getReqInfo().setUserId(user.userId());
和硬编码方案最大的不同在第 6 行。
token 不再和一个固定字符串比对,而是拿去数据库查用户。查到了就把用户信息写入请求上下文,查不到就拒绝。

这里有一个设计值得说一下——求职派用的 token 是用户的微信 ID(wxId)。用户通过微信扫码登录后,系统已经存了 wxId 和用户信息的对应关系,直接拿来当 MCP 的 Bearer token,不需要再生成一套独立的 API Key。
MCP 端点的判断也没有硬编码路径,而是从 YAML 配置里读取的。
@Value("${spring.ai.mcp.server.sse-endpoint:/sse}")
private String sseUrl;
@Value("${spring.ai.mcp.server.sse-message-endpoint:/mcp/messages}")
private String msgUrl;
@Value("${spring.ai.mcp.server.streamable-http.mcp-endpoint:/api/mcp}")
private String mcpUrl;
如果以后 MCP 端点路径改了,拦截器不用动代码,跟着配置走就行了。
03、用户的 MCP 配置从哪来
MCP 客户端需要一段 JSON 配置才能连接服务端,这段配置里包含了鉴权用的 Bearer token。
求职派不需要用户自己拼这个配置,而是在用户详情接口里直接返回。
用户登录后调用 /api/user/detail,返回的数据里就带了一个 MCP 配置对象(app/.../user/service/UserService.java)。
private McpConfigVo buildMcpConfig(UserEntity user) {
return new McpConfigVo(
mcpServerProperties.getProtocol().name(),
SpringUtil.getBean(SiteConfig.class).getWebSiteUrl() + buildUrl(),
mcpServerProperties.getVersion(),
Map.of("Authorization", "Bearer " + user.getWxId())
);
}
这个方法做了三件事。
根据配置的协议类型(SSE 或 Streamable HTTP)拼出完整的 MCP Server 地址,读取当前的协议版本号,最后用用户的微信 ID 生成 Bearer token 写到 headers 里。
返回给前端的数据结构是一个 record 类。
public record McpConfigVo(
String type, String url, String version, Map<String, String> headers
) {}
用户在求职派的管理后台登录后,页面上就能直接看到自己的 MCP 配置,复制到 Claude Desktop 或 Cursor 里粘贴就能用。

整个流程对用户来说是零门槛的,不需要手动去找 API Key,不需要去看文档拼 URL,登录就能拿到。
这个设计让鉴权变得很丝滑。
后端生成配置 → 前端展示 → 用户复制粘贴 → 客户端带上 token 发请求 → 拦截器校验 → 写入上下文,整条路径闭合了。
04、鉴权之后还要做什么?
鉴权只是第一步。
知道了“谁在调用”之后,更重要的是让后续的工具方法也能知道是“谁”。
求职派用了一个基于 TransmittableThreadLocal 的请求上下文来做这件事(core/.../apis/context/ReqInfoContext.java)。
public class ReqInfoContext {
private static TransmittableThreadLocal<ReqInfo> contexts =
new TransmittableThreadLocal<>();
public static void addReqInfo(ReqInfo reqInfo) { contexts.set(reqInfo); }
public static ReqInfo getReqInfo() { return contexts.get(); }
public static void clear() { contexts.remove(); }
}
这里用的是阿里的 TransmittableThreadLocal 而不是标准的 ThreadLocal,原因和 MCP 的通信方式有关。
MCP 的 SSE 连接是异步的,请求从 Servlet 线程进来之后可能会跳到 Reactor 的弹性线程池去执行。

标准的 ThreadLocal 在线程切换时会丢失上下文,用户信息就没了。
TransmittableThreadLocal 能在线程池提交任务时自动把上下文传递过去,保证 MCP 工具执行时还能读到用户身份。
整个鉴权 + 上下文传递的流程可以用四步概括。
- 请求记录过滤器初始化一个空的请求上下文,设置好 IP、设备 ID 等基础信息
- 权限校验拦截器从 Authorization 头里提取 token,去数据库查用户
- 查到用户后,把 userId 和 user 对象写入请求上下文
- MCP 工具方法执行时,通过
ReqInfoContext.getReqInfo()读取用户信息
有了用户上下文,岗位推荐工具就能知道是哪个用户在查岗位,可以记录操作日志、做个性化推荐、按用户统计调用量。

求职派的岗位推荐工具在执行时会打一行日志,记录用户昵称和查询条件,这些信息就是从请求上下文里读出来的。
如果没有这一层,所有的 MCP 调用在服务端看起来都是匿名的。鉴权通过了,但工具不知道是谁在用。
鉴权不只是在门口查证件,更重要的是让屋里的人知道进来的是谁。
05、生产环境还需要考虑什么?
求职派当前的方案已经能满足业务需求,但如果系统规模继续扩大,还有几个方向值得关注。

①、token 的有效期和撤销机制。wxId 作为 token 的好处是简单,但它本身没有过期时间,也不能单独撤销。
如果某个用户的 token 泄露了,目前只能改数据库里的 wxId 或者封禁账号。引入 JWT 签名的短期 token + refresh token 机制可以解决这个问题,
②、请求频率限制。
MCP 工具调用可能触发爬虫、AI 推理等重操作,不限频的话一个用户就能把服务打满。可以在拦截器层按 userId 做限流,但限流策略和 MCP 的工具类型相关,岗位查询可以宽松一些,浏览器操作和数据采集需要更严格的限制。
③、权限粒度细化。
当前的鉴权只区分“能不能调 MCP”,不区分“能调哪些工具”。
求职派已经有了角色体系(管理员、VIP、普通用户),后续可以把角色和 MCP 工具的访问权限绑定,比如数据采集类工具只对 VIP 和管理员开放,普通用户只能使用岗位查询工具。
④、审计日志也不能少。
鉴权让系统知道了“谁在调用”,但如果不记录下来,出了问题还是查不到。把每次 MCP 工具调用的用户身份、时间、工具名称、参数写入审计表,排查问题和计费统计都需要这些数据。
这些都是在“鉴权”这个基础能力之上的延伸。
06、如何把求职派写到简历上?
项目名称:求职派(JobClaw)
项目简介:OpenClaw 风格的多 Agent 求职系统,支持微信/钉钉/飞书多渠道接入,通过 MCP 协议对外暴露岗位推荐、数据采集等 20+ 工具能力,内置完整的用户鉴权和权限管控体系。
技术栈:Java 21、Spring Boot 4.0、Spring AI 2.0、MCP 协议、JWT、TransmittableThreadLocal、JPA/Hibernate
核心职责:
- 设计 MCP Server 的两层鉴权架构(请求过滤器 + 权限拦截器),过滤器负责初始化请求上下文和跨域处理,拦截器按请求类型分别走 Web 登录校验和 MCP Bearer Token 校验
- 实现 Bearer Token 与用户体系的绑定,将用户微信 ID 作为 MCP 认证凭证,通过数据库查询完成身份验证
- 基于 TransmittableThreadLocal 实现 MCP 请求的用户上下文跨线程传递,在 SSE 异步通信场景下保证工具方法执行时仍能读取到完整的用户身份信息
- 实现 MCP 配置自动生成,用户登录后通过详情接口直接返回包含 Bearer Token 和服务地址的配置对象,Agent 零配置接入
- 设计基于角色的 MCP 权限体系(管理员/VIP/普通用户),通过自定义注解标注 Controller 方法的权限等级,拦截器统一执行角色校验,支持工具级别的访问控制
回复