MCP Token 认证实战:从登录与会话隔离到 Blog MCP 接入踩坑
复盘 Blog MCP 接入中的 Token 签发、Redis 授权、内部内省、Codex Bearer 配置与会话隔离,梳理 502、401、503、伪跨域、.env 部署等问题,并说明 scopes 向工具禁用列表迁移时的权限风险。
2026 年 10 月 26 日,我给博客接入 MCP 时,先后遇到了 502、401、认证服务 503,以及看起来像跨域的启动故障。最后通过 MCP 握手、工具发现和文章列表查询,才确认整个调用链跑通。本文于 10 月 28 日整理,结合当时的排障记录和当前代码,讲清 Token 怎样签发、存在哪里、如何校验,以及哪些配置最容易出错。
本文的实践对象是通过 Streamable HTTP 访问的 Blog MCP。文中的 /api/mcp/auth/*、mcp_ 前缀、Redis 授权结构和工具禁用列表属于本项目实现,不是所有 MCP 服务都必须采用的格式。所有账号、密码和 Token 示例均为占位符。
一、先分清四种凭据
一个连接中同时存在用户身份、MCP 访问凭据、上游 API 凭据和服务间凭据。排查前先明确失败发生在哪一跳,能避免把所有问题都归结为 Token 失效。
| 凭据 | 用途 | 持有方 |
|---|---|---|
| MCP Token(mcp_…) | Agent 访问 Blog MCP /mcp;通过博客认证接口登录或换取得到 | 使用端保存;MCP 请求处理期间接收 |
| API JWT | MCP 以当前用户身份调用博客 /api/admin/* | 认证后端加密保存,可信 MCP 会话在内存中使用 |
| MCP_INTERNAL_SERVICE_TOKEN | 保护 MCP 到博客内部 introspect 的调用;两端必须相同 | 仅 Blog MCP 与 blog-server |
| MCP_TOKEN_ENCRYPTION_KEY | 加密 Redis 中关联的 API JWT;64 位十六进制字符串,表示 32 字节密钥 | 仅认证后端 |
内部服务密钥不是登录返回的 MCP Token,加密密钥也不是用于 Authorization 的值。两种服务端密钥应分别生成,不能复制成同一个值。此项目内部服务密钥要求 32~1024 字符;可分别使用 openssl rand -hex 32 生成随机值,再交给部署环境管理。
二、完整认证链路:登录、授权记录、内省与业务调用
用户账号密码 ── POST /api/mcp/auth/login ──┐
已有 API JWT ── POST /api/mcp/auth/exchange ─┤
↓
后端验证管理员身份与 API JWT
↓
签发独立 MCP Token,Redis 保存授权
↓
Agent ── Authorization: Bearer <MCP_TOKEN> ──→ Blog MCP /mcp
↓
POST /api/mcp/auth/introspect
x-mcp-service-token: <INTERNAL_SERVICE_TOKEN>
body: { "token": "<MCP_TOKEN>", "resource": "blog-mcp" }
↓
后端返回当前身份、到期时间及 API JWT
↓
MCP 绑定会话身份,并用 API JWT 调用博客 API第一步,登录复用博客现有管理员账号体系和密码校验,不维护另一份账号密码。已有 API JWT 的客户端可以调用 exchange,避免重复输入密码。当前博客管理 API 只允许管理员,成功登录 MCP 不会自动获得普通用户的数据权限,也不会自动实现文章作者或租户隔离。
第二步,后端生成 32 字节随机数,以 base64url 编码并加上 mcp_ 前缀,形成不透明的 MCP Token。它不是可以在客户端解码出用户信息的 JWT。后端保存 Token 的 SHA-256 摘要,用摘要定位授权记录;API JWT 使用 AES-256-GCM 加密,并与授权 ID 绑定。
第三步,每个 MCP HTTP 请求都携带 Bearer Token。MCP 调用受内部服务密钥保护的 introspect,确认授权仍有效、目标资源为 blog-mcp、账号状态未改变,并取得关联的 API JWT。远程会话按这份身份执行,不回退到服务器配置的公共管理员账号。
MCP Token 用于 MCP,上游 API JWT 用于博客 API,两个接收方各自验证自己的凭据。不要把任意上游 Token 当成 MCP Token 直接透传;也不要把内部 introspect 返回的 API JWT 放进工具结果交给模型。
三、Token 存储在哪里,过期和退出怎样生效
| 数据 | 保存位置与生命周期 |
|---|---|
| 用户密码 | 原账号数据库使用密码哈希;MCP 授权记录不保存密码。若使用本地自动登录 helper,本机账号文件另行保存凭据,需要限制访问。 |
| MCP Token 原文 | 登录响应交给使用端;服务端不持久化原文。 |
| Token 摘要与授权 | Redis;记录 grantId、用户、资源、过期时间、账号版本和授权版本,并设置 TTL。 |
| 关联 API JWT | Redis 中加密保存;内省成功后,MCP 在对应会话内存中使用。 |
| MCP 协议会话 | MCP 实例内存;进程重启后客户端需要重新 initialize。 |
MCP_TOKEN_EXPIRES_AT = min(
API_JWT_EXPIRES_AT,
当前时间 + MCP_TOKEN_TTL_SECONDS
)本项目默认授权时长为 3600 秒,实际有效期不会超过关联 API JWT。应以登录响应中的 expiresAt 和 expiresIn 为准,而不是假设每次都有完整一小时。Token 过期后需要重新登录或换取;当前流程没有实现 OAuth refresh token 的自动续期。
GET /api/mcp/auth/me 查询当前授权;POST /api/mcp/auth/logout 撤销当前授权;POST /api/mcp/auth/logout-all 撤销当前用户已有的全部 MCP 授权。这些接口携带 MCP Token。logout-all 不会撤销原 API JWT:原 JWT 仍有效时,可以再次 exchange。
Redis 共享的是授权记录,不是 MCP 协议会话。PM2 重启后,若 Redis 记录仍在,Token 可以继续校验,但会话必须重新建立;Redis 持久化、授权记录丢失和加密密钥变更都会影响恢复能力。多个 MCP 实例还需要处理会话路由,不能仅因为使用 Redis 就假定可以随意切换实例。
四、使用端配置:Bearer 变量名与 Token 原文分开
账号密码请求发往博客 API 的认证接口,Agent 的 MCP 地址则是 MCP 服务的 /mcp。二者不是同一个入口。下面使用示例域名,实际接入时替换为自己的 HTTPS 地址。
POST https://api.example.com/api/mcp/auth/login
Content-Type: application/json
{
"username": "<ADMIN_USERNAME>",
"password": "<ADMIN_PASSWORD>",
"resource": "blog-mcp"
}返回的 accessToken 才是使用端需要的 MCP Token。已有博客 API JWT 时,调用 POST /api/mcp/auth/exchange,请求头使用 Authorization: Bearer <API_JWT>,请求体可以为 {}。这里省略 scopes 不表示只读;当前工具权限规则见下一节。
[mcp_servers.blog]
enabled = true
url = "https://mcp.example.com/mcp"
bearer_token_env_var = "BLOG_MCP_TOKEN"bearer_token_env_var 填写环境变量名称 BLOG_MCP_TOKEN,变量值为 mcp_… 原文,不带 Bearer 前缀。Codex 会据此构造 Authorization。变量必须存在于 Codex 进程可读取的环境中:写进远端 blog-server 的 .env,或只在另一个终端 export,都不能保证桌面 Codex 能读到。
[mcp_servers.blog]
enabled = true
url = "https://mcp.example.com/mcp"
http_headers = { Authorization = "Bearer <MCP_TOKEN>" }上面是静态请求头的替代写法,值需要包含 Bearer 和一个空格。选择一种 Authorization 来源即可,避免旧配置遮蔽新凭据;Token 不应进入 URL 查询参数、公开示例或日志。更新配置后重新连接,并检查实际运行连接读取的配置。
OpenAI 官方文档:Codex MCP 的 HTTP 认证配置
当前项目还提供 http_headers_helper,可在连接时读取本机账号文件,调用登录接口并输出 Authorization JSON。账号文件应置于仓库外并限制为本人读写,helper 的 stdout 只能输出 JSON。连接时自动登录不等于原会话无缝续期:本服务会话绑定具体 Token,换 Token 后通常还要重新初始化连接。
五、权限是版本相关的:旧 scopes 不能证明当前只读
10 月 26 日的版本曾根据 scopes 发现 6 个只读工具,并成功查询到 9 篇文章。这是当时的验证结果,不能当成当前版本的固定工具数量或权限保证。10 月 28 日核对 MCP 代码时,策略已改成工具禁用列表,旧 scopes 字段仅为兼容保留,不再限制工具调用或文章发布。
当前 MCP 服务默认允许全部工具。服务级 DISABLED_TOOLS 和可信内省响应中的 disabledTools 合并生效;省略身份级 disabledTools 相当于没有额外禁用项。限制同时作用于工具列表和直接工具调用,不能只隐藏列表却允许通过名称绕过。
要实现只读,应根据实际工具清单禁用全部写入工具,覆盖文章、分类、资源、面试题和标签,并验证直接调用也被拒绝。仅提交 scopes: ["articles:read"] 不足以建立只读边界;新增工具后还要复核禁用列表。
当前本地 blog-server 授权代码仍保存并返回旧 scopes,尚未看到保存和返回 disabledTools 的完整链路。因此,不能假定每用户工具限制已经端到端接通。服务级限制可以独立使用;身份级限制需要后端真正持久化授权配置,并在可信 introspect 响应中返回。把 disabledTools 随意塞进登录请求或普通 HTTP 请求头不会自动生效。
这是升级时需要单独验收的兼容性问题:旧的受限 Token 在新 MCP 策略下可能获得更多工具。发布前应完成策略迁移、核对内省结果并重新建立会话;工具级禁用也不提供“允许创建草稿但禁止发布”这样的细粒度状态权限,后者仍需业务规则校验。
六、实际踩过的坑:现象、原因与最终处理
| 现象 | 实际原因 | 处理与注意事项 |
|---|---|---|
| 提示 Token 对应的环境变量不存在 | 把 mcp_… 原文填进 bearer_token_env_var;随后也出现过变量没有进入 Codex 环境 | 区分变量名和变量值,确认调用进程可见;或改用完整静态 Authorization。 |
| npm 启动后没有按预期进入 HTTP 模式 | npm 参数没有通过 -- 转发 | 使用 npm run start -- -t streamable;传输类型与 /mcp 路径要匹配。 |
| 本地有 .env,远端启动仍缺服务密钥 | 最初没有自动加载 .env,发布时也曾漏传 | 补充启动加载与发布同步;检查运行进程环境及重启结果。 |
| 怀疑 .env 被读成 /projects/.env | 误把 import.meta.url 当成调用方 build/index.js 的路径 | 实际定义在 build/utils/env.js,../../.env 指向项目根目录;应从定义模块位置计算。 |
| 启用 MCP 后后台显示跨域错误 | 先后出现开关拼成 truee、Redis 配置缺失,导致服务启动失败 | 先确认后端存活、健康检查和网关响应,避免直接放宽 CORS。 |
| 后台再次显示跨域,Nginx 返回 502 | PORT=3000 与 Docker 冲突,而 Nginx 转发到 7002 | 恢复并统一后端端口为 7002,核对监听端口与反向代理一致。 |
| Token 的身份接口返回 200,MCP 却报认证服务 503 | 失败已经位于 MCP 调用博客内部认证的链路 | 检查 API 基地址、introspect 可达性、服务密钥、响应结构和线上部署版本,不必盲目重发 Token。 |
| 本地改好,线上错误没有变化 | 源码、构建产物、远端 .env、进程环境相互独立;没有完成发布或重启 | 确认配置来源,构建并部署正确服务,重启后用实际请求验证。 |
| 直接连接成功,聊天仍没有工具 | 服务连接、工具加载与当前聊天的连接状态没有同步 | 重新连接,分别验证 initialize、tools/list 和实际业务查询。 |
当时还发现,本地与远端 .env 内容并不相同。后来发布脚本改成上传并覆盖本地存在的 .env,远端权限按当时要求放宽为 0666。覆盖行为需要纳入发布检查,否则本地开发配置可能覆盖线上 PORT、数据库或认证参数。0666 意味着所有本机用户都可读写,属于那次部署的具体选择,不应当作 MCP 认证的必要条件;一般应按运行账号设置最小读取权限。
当前启动器会自动加载项目根目录 .env,但已存在的进程环境变量优先。即使文件已同步,旧的进程环境仍可能覆盖它;使用 PM2 时需要检查应用环境,更新托管环境变量后按需使用 --update-env 重启,并复核最终监听地址。
七、哪些已经修好,哪些只是项目选择
截至本文核对代码时,MCP_AUTH_ENABLED 已从 blog-server 移除,认证功能固定启用;这不意味着可以匿名访问,登录、Token 校验、服务密钥与授权存储仍然需要正常工作。早期“设置 MCP_AUTH_ENABLED=true”的说明已过时。
Blog MCP 启动配置不再读取 BLOG_API_BASE_URL,API 基地址固定为 https://www.junfengshow.com/api;Redis 连接参数已迁入 blog-server 的 redis.config.ts。这些是本项目的配置选择,不是 MCP 协议要求。修改对应代码需要重新构建、部署并重启;通用项目可以采用环境配置或 Secret 管理,但不要把真实密码、密钥写进公开源码。
文章编写时还发现部分 README 段落仍保留旧开关、旧 Redis 环境变量和旧 scopes 描述。遇到文档与行为冲突,应同时核对当前代码、实际运行版本和部署配置,并补齐文档,不能只按一条旧报错反复修改。
八、按失败层次排查,不要只看状态码
| 现象 | 优先检查 |
|---|---|
| 502 Bad Gateway | Nginx 上游进程是否启动、端口是否占用、代理目标是否正确、Host 是否匹配。 |
| 401 授权拒绝 | 是否携带 Bearer、Token 格式/有效期/撤销状态;内部服务密钥错误也可能被映射为 401。 |
| 403 或 MCP 工具错误 | 管理员资格、服务与身份的工具禁用规则、上游 API 权限;并非所有业务错误都使用 HTTP 403。 |
| 503 Authentication service unavailable | 从 MCP 主机检查 introspect、超时、Redis、后端服务与响应结构;避免只在客户端验证登录。 |
| 400 / 404 会话错误 | 是否先 initialize、是否复用过期或重启前会话、Token/授权是否已变更。 |
| 浏览器显示跨域 | 先看真实 HTTP 状态和后端健康状态,再验证 OPTIONS 与响应头;502 也可能呈现为跨域。 |
1. 后端进程与实际监听端口
2. Nginx 上游与健康检查
3. 博客认证接口:login / me
4. MCP → API 的内部 introspect 链路
5. MCP initialize → notifications/initialized
6. tools/list → tools/call(例如查询文章)
7. 过期、撤销、换 Token、重启后的重新连接测试 Streamable HTTP 时,先完成 initialize,使用协商后的协议版本,发送 notifications/initialized,再发现和调用工具;后续请求按需携带 mcp-session-id。优先使用 MCP SDK 处理协议细节。HTTP 200 只是传输层成功,还应检查 JSON-RPC error、工具结果的 isError,以及返回的业务数据。
接口级检查、curl 或 SDK 检查可以验证传输与认证,但不等于已经完成浏览器 CORS 验证;同样,能获取工具列表也不等于业务 API 调用成功。本次最终使用真实文章列表查询确认了业务链路。
九、会话、安全边界与标准 OAuth 的区别
会话 ID 用来定位协议状态,不能代替身份凭据。本项目对每个 HTTP 请求重新认证,并把 Token 和授权身份摘要绑定到会话;拿到别人的 session ID 也不能跨身份复用。Token 或授权变化后应新建会话,服务端重启后也需要重新初始化。
公网客户端到 MCP 应使用 HTTPS,MCP 到 API 的内部认证链路同样需要 TLS 或受信网络保护。反向代理需要转发 Authorization、MCP 会话和协议相关头。HTTP 上传使用 base64 加文件名,不允许用 filePath 读取 MCP 服务器文件;stdio 的可信本地文件访问属于另一种边界。
本文实现的是“自定义登录换 Token,再由客户端携带 Bearer”的接入方式。只存在 login、exchange 和 introspect,并不代表已经实现 MCP 标准 OAuth 授权。需要客户端自动发现认证服务、交互授权和标准互操作时,应补齐规范要求的 OAuth 2.1 流程、受保护资源元数据、授权服务器发现、资源绑定和 PKCE 等机制。
这次接入中最有用的做法,是让每一个成功结论都对应一段可验证链路:账号能登录、MCP Token 能校验、内部内省能返回正确身份、会话能初始化、工具权限符合预期,最后业务查询返回正确数据。只有这些环节都成立,才能判断连接已经可用。
