背景与目标
我们团队维护一个内部数据服务,管实验数据的查询、文件和报告,最近在做的事情是把它暴露成 MCP Server,让 agent 直接查数据、下载文件、导出报告。
工具写完只是一个开始。这个服务里的数据涉及部门和委托公司,不是随便谁都能看的,所以认证和权限必须先解决。企业内部有现成的单点登录服务(下文简称 SSO),最终目标是:用户在 agent 平台里点一次登录,走 SSO 完成认证,之后 MCP 工具调用自动带上身份,数据权限对齐既有系统的语义。
这件事在 9 月于测试环境全链路跑通了。这篇文章记录协议原理、实现拆解和踩过的坑,协议部分和语言无关,换技术栈也能照着做。
主流架构:MCP 授权是什么设计
先说结论:MCP 的授权规范复用了 OAuth 2.0,把 MCP Server 定位成受保护资源(Resource Server),而不是授权服务器。你的 server 从头到尾不碰密码、不发 token、不管理会话,只做一件事:校验请求头里的 Bearer JWT。
我们这次的完整架构长这样:
┌─────────┐ 登录+授权(PKCE) ┌─────────┐ ①授权码流(OAuth客户端) ┌─────────────┐
│ Browser │ ───────────────> │ SSO │ <───────────────────── │ agent 平台 │
│ (用户) │ <─ 302 code 回调 │ (OIDC) │ ②JWT access token │ (连接服务) │
└─────────┘ (平台固定回调) └────┬────┘ ③token托管/自动续期 └──────┬──────┘
│ │ ⑤下发连接
│ ④ JWKS 公钥(server拉取缓存) ▼
▼
┌────────────────────────────────────────────────┐
│ MCP Server(任意技术栈) │
│ ⑥ 收 Bearer JWT → 本地验签 → 工具级数据权限 │
└────────────────────────────────────────────────┘
流程分解一下:
- 客户端没带 token 请求 MCP Server,server 返回
401 + WWW-Authenticatechallenge; - 客户端按 challenge 里的地址自动拉取资源元数据,再顺藤摸瓜找到授权服务器(SSO);
- 用户在浏览器里完成 SSO 登录授权(授权码 + PKCE);
- 平台侧的 OAuth 客户端(连接服务)拿 code 换 JWT access token,并且托管它,过期自动用 refresh_token 续期,token 不落到用户终端;
- 之后每次工具调用都带
Authorization: Bearer <JWT>,server 本地验签,通过后按用户身份做数据权限。
这个设计有个让我很满意的地方:MCP Server 不关心调用方是谁、token 在哪托管。它只认合法的 Bearer JWT。将来接入方式从平台托管换成别的(比如 MCP Gateway),server 侧代码一行不用改。
三个参与方的分工也很干净:
| 角色 | 干什么 |
|---|---|
| MCP Server(自己写) | MCP 协议端点 + OAuth 资源服务器语义(401 / 元数据 / 验签)+ 工具数据权限 |
| SSO 服务(管理员配合) | 注册 OIDC 客户端、签发 JWT access token、提供 JWKS 公钥 |
| agent 平台 | OAuth 客户端逻辑、token 托管与续期、连接下发 |
Server 端的实现
我们的技术栈是 Java/Spring,又不想引入新依赖,所以走的是手写路线(SSE 控制器 + JSON-RPC 手写解析,JWT 验签用 JDK 自带的 Signature + JWKS 解析)。整套代码按协议层、认证层、工具层分了三块。下面按层拆。
传输层:只实现 Streamable HTTP
MCP 有几种传输方式,规范当前推荐的是 Streamable HTTP:同一个 URL 接收 POST 和 GET。旧的 HTTP+SSE 双端点传输已经标记废弃,而且实测 agent 平台的共享连接模式只支持 Streamable HTTP。所以新的 server 只实现这一种就够了。
协议本身极简,最小可用的 server 只需要实现三个 JSON-RPC method:
initialize:握手,互相报协议版本和能力;tools/list:返回工具名、描述和 inputSchema(JSON Schema);tools/call:执行工具,返回结果。
几个容易忽略的细节:通知类消息(没有 id 的 JSON-RPC)要返回 HTTP 202 空体;initialize 完成后客户端会发 notifications/initialized,这也是通知;未知 method 返回 -32601,JSON 解析失败返回 -32700。
工具注册我们做成了 SPI 风格:容器里所有实现了 McpTool 接口的 Bean 自动收集进注册表,新增工具只加一个类,协议层零改动。
401 challenge:自动化发现的入口
所有 MCP 端点在无 token 或 token 无效时返回:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resour…ata="https://<host>/v1/0/mcp/.well-known/oauth-protected-resource"
关键是 resource_metadata 这个字段。MCP 客户端收到 401 后会自动走这条链:拉资源元数据 → 解析出 authorization_servers → 拉授权服务器的发现文档 → 发起授权码流。你不需要写任何"引导用户去登录"的代码。
配套要提供一个 RFC 9728 元数据端点:
{
"resource": "https://<host>/v1/0/mcp",
"authorization_servers": ["https://sso.example.com/oidc"],
"scopes_supported": ["openid", "profile"],
"bearer_methods_supported": ["header"]
}
这里有个好玩的细节:客户端对带路径的 issuer 会依次尝试几种 well-known 路径,其中一种是 {path}/.well-known/openid-configuration,正好命中 SSO 的发现端点位置,所以 SSO 侧不需要为发现做任何额外配置。
JWT 验签:完全本地化
验签不依赖任何外部服务(不调 introspection、不走网关),每次请求的校验链:
- 解析三段式 JWT,
alg走白名单。我们的 SSO 实测用的是 RS512,不是大家习惯的 RS256,白名单要覆盖 RS256/384/512,同时拒绝none; - 公钥从 SSO 的 JWKS 端点拉取,本地做 RSA 验签。JWKS 建议缓存 5 分钟左右,遇到未知
kid时强刷一次再试(应对换钥); - 校验 claims:
iss(这里有个大坑,后面单独说)、exp(±60 秒时钟容忍)、有nbf则校验,aud配置了才校验; - 通过后把用户身份放进请求上下文(一个 ThreadLocal Holder),供工具层做数据权限。
认证层做成了可插拔模块:一个 Servlet Filter + 一个 well-known controller + 一个验签器,配置里有 enabled 开关(默认关,保持现网匿名行为)、issuer、accepted-issuers、jwks-uri 几项。将来如果迁 MCP Gateway,这个包整体搬走就行。
SSO 侧的配置
SSO 侧是一次性配置,注册一个 OIDC 客户端,几个关键项:
- 打开"签发 JWT access token"的开关,不打开的话默认发 opaque token,后面验签必然失败,这是所有坑里最值钱的一个;
- 授权类型包含
authorization_code和refresh_token,后者用于 token 过期(8 小时)后自动续期; - 打开免确认授权,免去每次登录的授权确认页;
- 回调地址注册的是平台的固定 URL(支持正则匹配),本机 OAuth 途径已被客户端关闭,无需注册
127.0.0.1; - 属性释放策略把目录服务(LDAP)的 mail、displayName、cn 映射进 token。
clientSecret 属于敏感凭证,只经线下渠道传递,进环境变量或配置中心,不进代码库、不进文档、不打日志。
四个坑,全是实测踩出来的
接入过程大部分时间花在了 SSO 的几个"非标准行为"上,全部实测复现过,列在这里给后来人:
坑一:SSO 默认发 opaque token。 客户端拿着一串不透明 token 来调 API,验签报 Malformed JWT。原因:客户端注册时没打开"签发 JWT access token"的开关,SSO 默认给的是不透明令牌而不是 JWT。解法就一个开关,但不知道能查一天。
坑二:签名算法是 RS512。 验签代码按业界习惯白名单只写了 RS256,结果报 Unsupported JWT alg: RS512。我们的 SSO 签 JWT access token 默认用 SHA-512,白名单放宽到 RS256/384/512。
坑三:iss 有两种形态。 access token 的 iss 是 SSO 服务根地址(https://host/sso),而 id_token 的 iss 才是 OIDC issuer(https://host/sso/oidc)。我们的 OIDC 发现配置指向 /sso/oidc,验签拿同样的值去比对 iss,直接 mismatch。解法是把"发现用的 issuer"和"验签接受的 iss"拆成两个配置项。
坑四:preferred_username 不是工号。 有用户反馈登录后日志里显示的不是自己的工号。排查发现 SSO 的属性释放策略把 LDAP 的 cn 映射成 preferred_username,而这个账号的 cn 是显示名。真正稳定的是 sub(principal id,实测等于登录工号),且不受属性释放策略影响。工具层取身份一律优先 sub。
排查这类问题有个实用技巧:认证失败的日志必须打出原因 + token 脱敏前缀(一眼就能区分不透明 token 和 eyJ 开头的 JWT)。加上这个之后,排障效率完全不一样。
工具级数据权限:agent 场景的两个特殊问题
认证解决"是谁",工具还要解决"能看什么"。我们把既有 Web 接口的权限语义对齐到了 MCP 工具层:系统角色门禁优先(非系统用户全拒绝),管理员白名单不受数据范围限制,普通用户按部门 + 委托公司双维度过滤,任何配置缺失或下游异常一律 fail-closed(不可见任何数据,绝不放行)。
这个过程中有两个 agent 场景特有的教训,值得单独记:
第一,“未认证"和"认证但无角色"必须严格区分。 前者走 401 流程,后者必须明确全拒绝。我们把访问级别显式建模成 ANONYMOUS / DENIED / AUTHORIZED 三态,混在一起迟早出事故。
第二,无权限时必须返回明确的拒绝消息,不能返回空列表。 这是 agent 和人的关键区别:人看到空列表就放弃了,agent 会把空列表理解成"没有数据”,然后换个参数再查,再查,再查……我们最早对 DENIED 用户返回空列表,日志里 agent 疯狂重试的画面至今记忆犹新。改成返回 Access denied: user xxx has no system role 这样的明确文本之后,agent 看一眼就停了。
端到端验收
自测不需要等平台侧就绪,curl 就能把链路验证个大概:
# ① 未认证探测:应返回 401 + WWW-Authenticate
curl -si https://<host>/v1/0/mcp | grep -i www-authenticate
# ② 资源元数据:authorization_servers 指向 SSO
curl -s https://<host>/v1/0/mcp/.well-known/oauth-protected-resource
# ③ 换 token(浏览器完成登录拿 code 后):
# 成功标志是 access_token 以 eyJ 开头,说明 JWT 签发开关生效
curl -s https://sso.example.com/oidc/accessToken \
-d grant_type=authorization_code -d code=<code> \
-d redirect_uri=<回调URL> -d client_id=<id> -d client_secret=<secret> \
-d code_verifier=<verifier>
# ④ 带 token 调 MCP
curl -s https://<host>/v1/0/mcp \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"sim","version":"1.0"}}}'
最后的验收清单:401 challenge 正常、元数据端点匿名可达、换出的 token 以 eyJ 开头、用户在平台里点 Connect 完成登录后连接变绿、工具调用返回数据且服务端日志出现 user=<工号>、无角色用户拿到明确拒绝消息、token 过期后 refresh_token 自动续期用户无感。
小结
回头看,这件事的技术含量不在某一段代码,而在把三方的职责边界理清楚:server 只做资源服务器,认证发现全靠标准协议自动流转,token 管理交给平台。把边界划对之后,每一侧的实现都不复杂。
真正花时间的是 SSO 那些非标准行为:opaque token、RS512、iss 双形态、preferred_username。这些都是"文档不会写,日志会告诉你"的坑,记下来,愿后人少熬几个夜。
如果你也在做类似的事,欢迎交流。协议层与语言无关,官方 SDK(TypeScript 的 @modelcontextprotocol/sdk、Python 的 FastMCP)都比手写省事;Java 生态的话 spring-ai 的 MCP starter 也可以,或者像我们一样手写,auth 包整体可迁移。