记一次 MCP Streamable HTTP 和 OAuth 认证改造
这两天折腾了一下博客的 MCP 接口。
一开始我以为只是 Claude Code CLI 和 Codex CLI 的兼容问题,结果查下来才发现,问题其实分了好几层:MCP Streamable HTTP、OAuth discovery、Bearer token、Nginx 反代,哪个地方少一点细节都会炸。
说实话,这种问题挺烦的,因为它不是一个报错就能定位的。客户端只会告诉你连不上,真正的原因要一层一层拆。
一开始的问题
我博客里本来就有一个 MCP 接口,入口是:
text
/api/mcp
我想兼容两种使用方式:
- Codex CLI 这种直接带 Bearer token 的模式
- Claude Code CLI 这种走 OAuth discovery 的模式
想法很简单:长期 token 给自动化工具用,OAuth 给 Claude Code CLI 这种需要发现认证端点的客户端用。
但实际接上去以后,认证不通,Streamable HTTP 也不太像标准实现。
最开始的实现更像一个普通的 stateless JSON-RPC POST 接口,只是名字叫 MCP。POST 能调,工具也能返回,但对于 MCP Streamable HTTP 来说,还缺不少协议层面的东西。
比如未认证时应该返回 401,而且要带 WWW-Authenticate:
http
WWW-Authenticate: Bearer resource_metadata="https://www.nnnnzs.cn/.well-known/oauth-protected-resource"
客户端看到这个以后,才知道去哪里拿 protected resource metadata,再继续 OAuth discovery。
如果这个头不标准,后面就不用看了,客户端直接懵。
先把 MCP Streamable HTTP 拉正
我先改的是 /api/mcp 本身。
之前接口虽然能处理 JSON-RPC,但没有完全按 Streamable HTTP 来。
后来改成基于 WebStandardStreamableHTTPServerTransport,把几个关键行为补上:
- 带 token 的
POST /api/mcp正常处理 JSON-RPC - 未认证请求返回
401和WWW-Authenticate - 已认证的
GET /api/mcp返回405 - notification 请求返回
202 - 响应保持 JSON 模式,兼容 Codex CLI 这种调用方式
验证的时候,我用 initialize、tools/list、resources/list 和 notification 都跑了一遍。
结果是:
text
initialize -> 200
工具列表 -> 6 个 tools
资源列表 -> 正常返回
notification -> 202
这一步以后,Bearer token 模式基本就通了。
OAuth discovery 的坑
然后问题转到 OAuth metadata。
MCP 相关的 OAuth discovery 至少要暴露两个东西:
text
/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server
protected resource metadata 里要告诉客户端:这个 MCP resource 是谁,authorization server 在哪里。
authorization server metadata 里要告诉客户端:authorize、token、revoke、introspect、register 等端点在哪里。
我一开始把这些都放在 .well-known 下,看起来没问题。
结果线上一测,发现 .well-known 直接 404。
真凶不是 Next.js,而是 Nginx。
我的 Nginx 里有这一段:
nginx
location ~ \.well-known {
allow all;
}
它本来是为了 ACME 证书验证留的,但副作用是 .well-known 请求被 Nginx 当静态文件处理了,没有转发给 Next.js。
所以 Next.js 里的 .well-known route 根本没机会执行。
这个问题挺典型:代码看着没问题,线上就是不生效,最后发现是反代规则把路截走了。
Nginx 也要配合
后面我把 Nginx 改成两类规则分开:
一类是证书验证用的 ACME 静态路径。
另一类是 OAuth discovery,要转发给 Next.js。
大概是这个意思:
nginx
location ^~ /.well-known/oauth-protected-resource {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /.well-known/oauth-authorization-server {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
这里还有一个小坑。
如果不传 X-Forwarded-Host 和 X-Forwarded-Proto,应用里生成 metadata 的时候可能拿到内部地址,比如:
text
https://0.0.0.0:3000
这肯定不行。
OAuth metadata 里的地址必须是客户端能访问的公网地址,也就是:
text
https://www.nnnnzs.cn
所以代码里也补了一层公共 origin 推断逻辑,优先读反代传进来的 host 和 proto,再兜底环境变量。
path-specific metadata 又补了一刀
本来我以为到这里就结束了。
结果再测的时候发现,Codex 这类客户端可能会访问 path-specific 的 protected resource metadata:
text
/.well-known/oauth-protected-resource/api/mcp
这个地址当时还是 404。
原因也很简单:我只做了固定路径:
text
/api/oauth-protected-resource
没有做 catch-all。
最后改成了 Next.js 的可选 catch-all route:
text
src/app/api/oauth-protected-resource/[[...resourcePath]]/route.ts
这样不管访问:
text
/.well-known/oauth-protected-resource
还是访问:
text
/.well-known/oauth-protected-resource/api/mcp
都能返回同一份正确 metadata,并且里面的 resource 指向真正的 MCP 地址。
最终线上返回是这样的:
json
{
"resource": "https://www.nnnnzs.cn/api/mcp",
"authorization_servers": ["https://www.nnnnzs.cn"],
"scopes_supported": ["read", "write", "admin"]
}
这才算把 discovery 链路补完整。
最后验证
最后我在线上完整测了一遍。
未认证请求:
text
GET /api/mcp -> 401
WWW-Authenticate -> 指向正确的 protected resource metadata
OAuth metadata:
text
/.well-known/oauth-protected-resource -> 200
/.well-known/oauth-protected-resource/api/mcp -> 200
/.well-known/oauth-authorization-server -> 200
带 token 的 MCP 请求:
text
initialize -> 200
resources/list -> 200
tools/list -> 200
tools/call list_articles -> 200
notifications/initialized -> 202
到这里,Codex CLI 的 Bearer token 模式没问题,Claude Code CLI 需要的 OAuth discovery 入口也都齐了。
这次的结论
这次最大感受是,MCP 接口不是“能 POST 一个 JSON-RPC”就完事了。
如果要兼容 Codex CLI、Claude Code CLI 这种真实客户端,协议边界要补齐:
- MCP Streamable HTTP 要按标准返回状态码和响应格式
- OAuth discovery 要把
.well-known做完整 - Bearer token 和 OAuth 可以共存,但资源端点和认证端点职责要分清楚
- Nginx 不能把
.well-known提前截走 - 反代必须传正确的公网 host 和 proto
这类问题没有特别玄学,就是细节多。
一个一个测下来,其实也还好。