
最近折腾了 MCP 服务器,想让 AI agent 能帮创始人自动发布产品、推送更新、发 changelog。搭完之后想接入 Claude Connectors Directory,结果一头撞上了 OAuth 2.1 + PKCE + 动态客户端注册这堵墙。踩了几个坑,这篇把问题和解决方案说清楚。
我运营的是 BetaFinds,一个早期产品发现平台。每个创始人发布产品后都要做同样的事:更新产品页、写"本次更新"帖子上传截图。这些工作重复度高、结构清晰,正好是 agent 擅长的场景。
所以玩法就变成了:**"Claude,帮我把 v2.1 版本发布到 BetaFinds"** —— 然后这事就成了。
要让这个设想落地,平台得能被 AI agent 驱动。这就是 MCP(模型上下文协议,Model Context Protocol)要解决的问题。
MCP 基于"可流式 HTTP"协议,本质上就是 JSON-RPC 2.0 走一个 POST 接口。实现三个方法就能跑起来:
initialize — 握手,声明服务器能力tools/list — 描述可用工具(JSON Schema 进,JSON Schema 出)tools/call — 实际执行操作我的服务器暴露了 10 个工具:create_startup、update_startup、publish_update、create_release、upload_image 等等。整个实现就是一个 Next.js 路由处理器。
export async function POST(req: NextRequest) { const user = await authenticateApiToken(req.headers.get("authorization")) const { id, method, params } = await req.json() switch (method) { case "initialize": return rpc(id, { protocolVersion, capabilities, serverInfo }) case "tools/list": return rpc(id, { tools: TOOLS.map(describe) }) case "tools/call": { if (!user) return unauthorized(id) // 👈 有意思的部分,后面再说 const tool = TOOLS.find(t => t.name === params.name) return rpc(id, await tool.handler(user, params.arguments)) } }
}
我第一版对所有请求都要求 token,包括 initialize 和 tools/list。这破坏了服务发现机制:注册表扫描器(Smithery、Glama、PulseMCP)访问 initialize 时遇到 401,就默认存在完整的 OAuth 流程,直接退出了。
解决办法:**只在 tools/call 时要求认证**。initialize / tools/list / ping 在没有 token 的情况下也要正常响应,这样扫描器、好奇的用户、目录爬虫都能先看看服务器"是什么",再决定是否连接。真正敏感的操作发生在用户实际调用工具时。
各个注册表基本不费什么劲。一个 server.json、一个公开仓库、再填几张表单,服务器就进了官方 MCP Registry、Smithery(加上工具 annotations 和 outputSchema 后质量分 90/100)、Glama(评级 A)和 mcp.so。方便被发现,没有门槛。
然后就遇到了真正重要的那个:Claude Connectors Directory —— 在 claude.ai、Desktop 和移动端对每个 Claude 用户可见。这个有门槛。
我的服务器原本用静态 token 认证:Authorization: Bearer blt_…。这种方式用在 claude mcp add --header 没问题,但对目录无效,因为它要求的是 OAuth 2.1 + PKCE(S256)。
两个选择:
/authorize 流程发出去而已。我选了 B,因为 A 选项里的"身份桥接"在我就是身份提供商的情况下是免费的:用户看到授权确认页时已经登录了我的应用,token 直接映射到他的账户,不需要任何粘合代码。
我给自己定了个规则:**旧的 blt_ token 继续能用**。OAuth 加在旁边,不是替代它。
MCP 客户端不会神奇地知道你要走 OAuth。它是从一个特定的 401 里得知的。触发整个流程的那个 header:
function unauthorized(id: unknown) { return Response.json( { jsonrpc: "2.0", id, error: { code: -32001, message: "Unauthorized" } }, { status: 401, headers: { // RFC 9728 — 这是客户端跟随的线索 "WWW-Authenticate": `Bearer resource_metadata="https://betafinds.com/.well-known/oauth-protected-resource"`, }, } )
}
然后客户端会走 well-known 文档链:
/.well-known/oauth-protected-resource)—— "这是资源,旁边那个是保护它的授权服务器"。/.well-known/oauth-authorization-server)—— 端点 + code_challenge_methods_supported: ["S256"]。// /.well-known/oauth-authorization-server
{ "issuer": "https://betafinds.com", "authorization_endpoint": "https://betafinds.com/oauth/authorize", "token_endpoint": "https://betafinds.com/api/oauth/token", "registration_endpoint": "https://betafinds.com/api/oauth/register", "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"]
}
Next.js 注意事项:App Router 对带点前缀的文件夹解析不太可靠,所以实际的路由放在
app/well-known/...,然后用rewrite把/.well-known/...映射过去。
目录(以及 MCP Inspector、每个通用 MCP 客户端)期望在运行时**动态注册客户端** —— RFC 7591。不需要预先共享 client ID。客户端是公开的(用 PKCE,不用密钥),所以注册流程相当简单:
export async function POST(req: NextRequest) { const { redirect_uris, client_name } = await req.json() // 后面会有精确匹配的允许列表;现在先验证协议(https,或者 http-localhost 用于 Inspector) if (!redirect_uris?.every(isValidRedirectUri)) return oauthError("invalid_redirect_uri") const client = await prisma.oAuthClient.create({ data: { name: client_name ?? "MCP Client", redirectUris: redirect_uris, scope: "mcp" }, }) return corsJson({ client_id: client.id, token_endpoint_auth_method: "none", /* … */ }, { status: 201 })
}
是的,开放注册意味着任何人都能创建一条客户端记录。这是 MCP 的设计预期 —— 安全靠 PKCE + 精确匹配的 redirect URI + 用户授权,不靠注册环节的门控。
授权端点是"我已经有 Auth.js"这件事发挥价值的地方。如果用户没登录,先把他们导向现有登录流程再回来:
const client = await prisma.oAuthClient.findUnique({ where: { id: clientId } })
if (!client || !client.redirectUris.includes(redirectUri)) { return <AuthorizeError/> // 绝不重定向到未经校验的 URI
}
if (codeChallengeMethod !== "S256" || !codeChallenge) { redirect(errorBack("invalid_request")) // PKCE 是强制的
} const session = await auth()
if (!session?.user) { redirect(`/login?callbackUrl=${encodeURIComponent(currentUrl)}`) // ← Auth.js 已经支持这个
}
return <ConsentForm client={client} user={session.user} .../>
点"允许"后,一个服务端 action 生成一个短效(60秒)、一次性使用的授权码,绑定到 client_id、redirect_uri、PKCE code_challenge,以及 —— 关键 —— session.user.id。最后这个绑定就是"不需要身份桥接"的精髓:token 对应的就是授权用户,仅此而已。
// authorization_code grant
const record = await prisma.oAuthAuthCode.findUnique({ where: { codeHash: sha256(code) } })
const bad = record.expiresAt < new Date() || record.clientId !== clientId || record.redirectUri !== redirectUri || !verifyPkceS256(codeVerifier, record.codeChallenge) // 先删除 — 删除本身就是一次性的关卡,防止重放
const { count } = await prisma.oAuthAuthCode.deleteMany({ where: { id: record.id } })
if (bad || count === 0) return oauthError("invalid_grant")
PKCE 校验就是一行代码,而且应该是时间安全的:
export function verifyPkceS256(verifier: string, challenge: string) { const computed = createHash("sha256").update(verifier).digest("base64url") return timingSafeEqual(Buffer.from(computed), Buffer.from(challenge))
}
我没新建 token 存储。OAuth access token 就是 api_tokens 表里的一行,加了几个可空列(type、expiresAt、refreshTokenHash、clientId)。所以 auth 函数几乎没变 —— 现在它接受任何 bearer token,查哈希,校验过期:
export async function authenticateApiToken(header: string | null) { if (!header?.startsWith("Bearer "