site logo

Marico's space

我搭建了一个 MCP 服务器,让 AI agent 能帮你发布创业公司 - 还为它接入了 Claude directory 的 OAuth

前端技术 2026-07-09 20:57:08 4

最近折腾了 MCP 服务器,想让 AI agent 能帮创始人自动发布产品、推送更新、发 changelog。搭完之后想接入 Claude Connectors Directory,结果一头撞上了 OAuth 2.1 + PKCE + 动态客户端注册这堵墙。踩了几个坑,这篇把问题和解决方案说清楚。

思路:让 agent 干那些重复的活

我运营的是 BetaFinds,一个早期产品发现平台。每个创始人发布产品后都要做同样的事:更新产品页、写"本次更新"帖子上传截图。这些工作重复度高、结构清晰,正好是 agent 擅长的场景。

所以玩法就变成了:**"Claude,帮我把 v2.1 版本发布到 BetaFinds"** —— 然后这事就成了。

要让这个设想落地,平台得能被 AI agent 驱动。这就是 MCP(模型上下文协议,Model Context Protocol)要解决的问题。

MCP 服务器:比你想象的简单

MCP 基于"可流式 HTTP"协议,本质上就是 JSON-RPC 2.0 走一个 POST 接口。实现三个方法就能跑起来:

  • initialize — 握手,声明服务器能力
  • tools/list — 描述可用工具(JSON Schema 进,JSON Schema 出)
  • tools/call — 实际执行操作

我的服务器暴露了 10 个工具:create_startupupdate_startuppublish_updatecreate_releaseupload_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)) } }
}

坑 #1:保持 introspection 无需认证

我第一版对所有请求都要求 token,包括 initializetools/list。这破坏了服务发现机制:注册表扫描器(Smithery、Glama、PulseMCP)访问 initialize 时遇到 401,就默认存在完整的 OAuth 流程,直接退出了。

解决办法:**只在 tools/call 时要求认证**。initialize / tools/list / ping 在没有 token 的情况下也要正常响应,这样扫描器、好奇的用户、目录爬虫都能先看看服务器"是什么",再决定是否连接。真正敏感的操作发生在用户实际调用工具时。

上架目录(简单的那部分)

各个注册表基本不费什么劲。一个 server.json、一个公开仓库、再填几张表单,服务器就进了官方 MCP Registry、Smithery(加上工具 annotationsoutputSchema 后质量分 90/100)、Glama(评级 A)和 mcp.so。方便被发现,没有门槛。

然后就遇到了真正重要的那个:Claude Connectors Directory —— 在 claude.ai、Desktop 和移动端对每个 Claude 用户可见。这个有门槛。

撞墙:目录要求 OAuth 2.1

我的服务器原本用静态 token 认证:Authorization: Bearer blt_…。这种方式用在 claude mcp add --header 没问题,但对目录无效,因为它要求的是 OAuth 2.1 + PKCE(S256)

两个选择:

  • A. 委托给第三方(Stytch/WorkOS 这类开箱即用的 MCP 认证方案)。大约 2-4 天搞定,但多了一个依赖,还要搭身份桥接层。
  • B. 自己跑一个最小化授权服务器。代码多一些,但我已经有 Auth.js(登录 + 会话)和 token 模型了。OAuth access token 跟我已发行的 token 差不多,就是通过 /authorize 流程发出去而已。

我选了 B,因为 A 选项里的"身份桥接"在我就是身份提供商的情况下是免费的:用户看到授权确认页时已经登录了我的应用,token 直接映射到他的账户,不需要任何粘合代码。

我给自己定了个规则:**旧的 blt_ token 继续能用**。OAuth 加在旁边,不是替代它。

发现握手(这才是让 Claude 启动的关键)

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 文档链:

  1. 受保护资源元数据/.well-known/oauth-protected-resource)—— "这是资源,旁边那个是保护它的授权服务器"。
  2. 授权服务器元数据/.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/... 映射过去。

动态客户端注册:让 Claude 自己注册

目录(以及 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 + 用户授权,不靠注册环节的门控。

Authorize + consent:复用你已有的会话

授权端点是"我已经有 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_idredirect_uri、PKCE code_challenge,以及 —— 关键 —— session.user.id。最后这个绑定就是"不需要身份桥接"的精髓:token 对应的就是授权用户,仅此而已。

Token 端点:PKCE 校验 + refresh 轮换

// 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))
}

顺手的地方:access token 就是我已有的 token

我没新建 token 存储。OAuth access token 就是 api_tokens 表里的一行,加了几个可空列(typeexpiresAtrefreshTokenHashclientId)。所以 auth 函数几乎没变 —— 现在它接受任何 bearer token,查哈希,校验过期:

export async function authenticateApiToken(header: string | null) { if (!header?.startsWith("Bearer "