太长不看版:把家里 MacBook 上的 MCP Server 用 Cloudflare Tunnel 暴露到公网之后,让 Claude / ChatGPT 稳定连上它的真正难点不是网络,而是 OAuth。我踩了六个坑:
① 未认证请求必须返回真正的 HTTP 401 + WWW-Authenticate,只在 JSON-RPC 层提示客户端不认;
② 授权页的 CSP form-action 会把跳回 ChatGPT 的回调拦死;
③ Claude 发来的 resource 多一个尾斜杠,严格字符串比较直接挂;
④ 能授权不等于好用,ChatGPT 还要消费完整的 tool metadata;
⑤ 没有 refresh token 的长会话体验是灾难;⑥ 自包含签名 token 撑不起单次 code、token 轮换和 consent,迟早要上服务端状态。展开细节见第 5 节。

我最近做了一个小实验:让 Claude、ChatGPT 这类托管 Agent 客户端,通过 MCP 安全地连上家里的 MacBook Pro。

一开始我以为,这件事无非就是三步:本地起一个 MCP Server,用 Cloudflare Tunnel 暴露出去,再在 Claude 或 ChatGPT 里填上远程 MCP 地址。结果真正做起来之后,我发现最麻烦的不是网络连通性,而是认证。

MCP Server 不能裸奔。尤其是当它背后连接的是我家里的电脑、脚本、文件系统或者其他自动化能力时,它本质上已经不只是一个 demo server,而是一个远程工具入口。ChatGPT 这类客户端会直接拒绝没有实现标准 OAuth 流程的远程 MCP Server;Claude 虽然允许添加无认证的远程 MCP,但当 server 背后挂着我自己的电脑时,裸奔本身就不可接受。于是这个小实验最后变成了一次 MCP OAuth 踩坑:从 401 challenge、OAuth metadata、Authorization Code + PKCE,到 refresh token、CSP、resource audience、tool metadata,一路把一个”能跑”的 MCP Server 修到能被 Claude / ChatGPT 稳定发现、授权和调用。

这篇文章不是 OAuth 教程,也不是 MCP 入门文档,而是一次真实的个人工程记录:我是怎么把家里的 MacBook 变成远程工具箱的,以及为了让托管客户端安全调用它,中间踩了哪些坑。

先说明边界:这不是生产级 OAuth Server 的推荐实现,而是个人自建 MCP 的最小可用安全实践。它解决的是”不要裸奔暴露 MCP Server”和”让 Claude / ChatGPT 能走标准 OAuth flow”这两个问题。真正的生产环境仍然应该使用成熟的 OAuth/OIDC 方案、独立身份系统、审计、权限隔离和密钥轮换。


1. 我到底做成了什么

最终结果很简单:

我在家里的 MacBook Pro 上跑了一个 MCP Server,通过 Cloudflare Tunnel 暴露到公网,然后让 Claude、ChatGPT 通过标准 OAuth 流程完成授权,再调用这个 MCP Server 里的工具。

也就是说,Claude / ChatGPT 不再只是聊天窗口里的大模型,而是可以通过 MCP 调用我家里机器上的能力:比如读写某些受控资源、触发本地脚本、访问我自己封装好的工具接口。

这个目标听起来很诱人,但它有一个非常明显的问题:

如果 MCP Server 直接暴露到公网,那就是裸奔。

本地调 MCP 很简单,因为请求来自本机或者受控环境;远程 MCP 完全不同,因为它面对的是公网入口。一旦你的 MCP Server 背后挂的是本地电脑能力,认证和授权就不是可选项,而是基本安全边界。

我第一次从 ChatGPT 连接这个 MCP Server 时,就遇到了下面的问题:

ChatGPT 拒绝连接未实现标准 OAuth 的远程 MCP Server

这张图背后的意思很直接:客户端发现你的远程 MCP Server 没有实现它期望的 OAuth 认证流程,所以拒绝继续连接。

这也是这篇文章真正的起点:

Cloudflare Tunnel 只解决”能不能连到”的问题,OAuth 才解决”谁可以连、以什么权限连、能连多久”的问题。


2. 为什么我觉得 MCP 对个人玩家的价值被低估了

最近大家聊得多的是 skill——怎么把经验和工作流沉淀成模型可复用的任务说明。我认同 skill 的价值,但这次折腾让我更在意另一件事:MCP 对个人玩家的价值被低估了。

简单区分一下:skill 告诉 Agent”这类事情应该怎么做”,MCP 告诉 Agent”外部世界有哪些能力可以被调用”。skill 里依赖的脚本、CLI、环境变量在不同平台上效果差异很大,而 MCP 把工具调用边界收敛成了标准协议——OS 差异、依赖安装这些脏活,全部隔离在 MCP Server 一侧。

对个人玩家来说,这意味着一件很有意思的事:只要我能在家里自建一个 MCP Server 并安全地暴露出来,我的电脑、脚本、服务和自动化能力,就都能变成 Claude / ChatGPT 可以直接调用的远程工具。

不过,”暴露出来”只是第一步。真正的难点是:

如何让这个自建 MCP Server 支持托管客户端认可的标准 OAuth 登录?


3. 先把 MCP Server 暴露出去

自建 MCP Server 不是本文重点,这里只简单带过。

大致流程是:

  1. 用 Anthropic、OpenAI 或社区 MCP SDK 写一个最小 MCP Server。
  2. 本地启动,让它监听 127.0.0.1:port
  3. 使用 Cloudflare Tunnel 把这个本地端口暴露成一个公网 HTTPS 地址。
  4. 在 Cloudflare Dashboard 里给自己的域名添加 route,把某个子域名指向这个 tunnel。
  5. 在 Claude / ChatGPT 里添加远程 MCP Server 地址。

如果只看网络链路,这样就已经打通了:

1
2
3
4
5
6
7
8
9
10
11
Claude / ChatGPT
|
| HTTPS
v
Cloudflare Tunnel
|
v
家里的 MacBook Pro
|
v
本地 MCP Server

但是这一步完成之后,不代表你就真的可以用了。

因为现在的问题变成了:你的 MCP Server 正在公网入口后面等待请求。如果没有认证机制,任何能访问这个地址的人理论上都可能尝试调用它。

所以接下来要解决的不是 MCP tool 怎么写,而是 OAuth 怎么补。


4. MCP OAuth 最小背景:客户端到底期待什么

熟悉 OAuth 2.1 + PKCE 的读者可以直接跳到第 5 节看坑。

OAuth 在这个场景里可以先理解成一套”不要直接交密码,而是通过授权服务器发 token”的流程。四个角色:

  • Resource Owner:我,资源拥有者。
  • Client:Claude、ChatGPT、Codex 这类要调用 MCP 的客户端。
  • Authorization Server:负责登录、授权、发 token 的服务。我的第一版里由 MCP Server 自己兼任。
  • Resource Server:真正提供 MCP tool 的服务,也就是 MCP Server。

在远程 MCP 场景里,客户端期待的是 Authorization Code Flow + PKCE,可以压缩成 5 步:

4.1 Client 访问 MCP,但没有 token

1
POST https://mcp.example.com/mcp

请求里没有 Authorization: Bearer xxx

4.2 MCP 返回 401,告诉客户端去哪里发现认证信息

MCP Server 应该返回真正的 HTTP 401,并带上 WWW-Authenticate header:

1
2
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

这一步非常关键。客户端不是靠猜,而是靠这个 challenge 进入 OAuth discovery 流程。

4.3 Client 读取 OAuth metadata

客户端先访问 /.well-known/oauth-protected-resource 拿到授权服务器地址:

1
2
3
4
{
"authorization_servers": ["https://mcp.example.com"],
"resource": "https://mcp.example.com"
}

再访问 /.well-known/oauth-authorization-server 发现各端点:

1
2
3
4
5
{
"authorization_endpoint": "https://mcp.example.com/oauth/authorize",
"token_endpoint": "https://mcp.example.com/oauth/token",
"registration_endpoint": "https://mcp.example.com/oauth/register"
}

4.4 浏览器打开授权页,用户登录并确认授权

1
2
3
4
5
6
7
GET https://mcp.example.com/oauth/authorize?
response_type=code
&client_id=xxx
&redirect_uri=https://chatgpt.com/...
&scope=mcp.read
&code_challenge=xxxx
&code_challenge_method=S256

code_challenge 就是 PKCE 的关键:它保证后面拿授权码换 token 的客户端,确实是最初发起授权流程的那个客户端,而不是中途截获 authorization code 的攻击者。

4.5 Client 用 authorization code 换 token,然后调用 MCP

授权成功后,浏览器 redirect 回客户端 callback 地址并带上一次性的 code,客户端再请求 token endpoint:

1
2
3
4
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=abc123&code_verifier=xxxx

服务端返回:

1
2
3
4
5
{
"access_token": "eyJxxxx",
"refresh_token": "xxxx",
"expires_in": 3600
}

之后客户端每次调用 MCP tool,都带上 Authorization: Bearer eyJxxxx,MCP Server 再检查 token 是否有效、scope 是否满足、audience/resource 是否匹配。

到这里,OAuth 的主流程就走完了。听起来并不复杂,但真正兼容 Claude、ChatGPT、Codex 时,坑基本都藏在细节里。


5. 从”能跑”到”真的好用”:我的 MCP OAuth 踩坑实录

我的这个 MCP 从 commit 459288c 开始实现 OAuth,基本是从”能跑通基础授权”,逐步修成”能被 ChatGPT / Claude / Codex 稳定发现、授权、长期使用,并补上关键安全状态”。

先放一个总表:

现象与根因 修复 关键收获
没有返回真正的 HTTP 401 ChatGPT 不触发 OAuth、像卡住——因为认证提示只放在 JSON-RPC result 里,HTTP 状态仍是 200 改成 HTTP 401 + WWW-Authenticate OAuth discovery 的触发信号在 HTTP 层
OAuth callback 被 CSP 拦住 授权后跳不回 ChatGPT——授权页 CSP 固定 form-action 'self' 按已验证的 redirect_uri origin 放行 form action OAuth 不是纯服务端 redirect 问题
Claude 的 resource 多一个尾斜杠 ChatGPT 正常、Claude 失败——resource 做了严格字符串比较 增加 canonicalResource(),统一按 origin 比较 校验语义,不要校验脆弱字符串
授权成功但 tool 使用不稳定 能连上,但工具展示和结果解析不稳——tool metadata 不完整 _meta.securitySchemesoutputSchemastructuredContent “能授权”不等于”好用”
频繁重新输入 passphrase access token 过期体验差——没有 refresh token,也没复用站点 session 增加 refresh token、offline_access、session 复用 长会话体验必须考虑 token 生命周期
自包含 token 安全语义不足 code 可在 TTL 内重放、refresh token 难轮换——没有服务端状态 用 D1 记录授权状态、单次 code、token rotation、consent 安全语义不能永远靠签名字符串硬撑

下面展开讲几个最关键的坑。


5.1 基线版本:先让 OAuth flow 能跑起来

459288c 是我的第一版 OAuth 实现。这一版先把主链路跑通了:

  • POST /mcp Streamable HTTP MCP Server。
  • /.well-known/oauth-protected-resource/.well-known/oauth-authorization-server
  • /oauth/register/oauth/authorize/oauth/token
  • 动态客户端注册。
  • Authorization Code + PKCE S256。
  • HMAC 签名的自包含 client_id、authorization code、access token。
  • MCP tool 级别 scope,区分 read 和 write。

这个版本的目标很朴素:先让 Claude / ChatGPT 能发现 OAuth endpoint,能打开授权页,能拿 code 换 token,能带 token 调 MCP tool。

但这只是”能跑”。当时最明显的问题有几个:

  • MCP 未认证响应不完全符合 OAuth/MCP 客户端的预期。
  • 没有 refresh token,access token 过期后体验很差。
  • authorization code 在 TTL 内存在重放风险。
  • OAuth 页面只是 passphrase 表单,不是真正的 consent 页面。
  • 对不同客户端的小差异兼容不够。

后面的几个 commit,基本都在补这些坑。


5.2 坑一:未认证 MCP tool call 必须返回真正的 HTTP 401

对应 commit:7a4f1cf

我一开始以为,在 JSON-RPC result 的 _meta["mcp/www_authenticate"] 里放认证提示就够了。也就是说,服务端虽然告诉客户端”你需要认证”,但 HTTP 状态码还是 200

结果 ChatGPT 这类 OAuth-aware MCP client 并不会因此进入 OAuth 流程。

问题表现很烦:它不是清晰地报错,而是看起来像”无响应”或者”卡住”。这个问题卡了我挺久,因为服务端没有明显错误日志,JSON-RPC 层看起来也确实返回了认证提示。

后来才意识到:OAuth discovery 的触发点不是 JSON-RPC 业务层,而是 HTTP 层。

修复方式是:

  • authorizeToolCall() 返回 { result, challenge }
  • rpcResult() 支持自定义 status/header。
  • 未授权 tool call 改成 HTTP 401,同时返回:
1
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

这个坑给我的第一个教训是:

MCP JSON-RPC 层的错误提示不够,OAuth discovery 的触发信号必须在 HTTP 层。

协议边界比业务逻辑更关键。你以为自己”告诉客户端需要认证”了,但客户端真正识别的是标准 HTTP challenge。


5.3 坑二:ChatGPT OAuth redirect 被 CSP 拦住

对应 commit:612c30b

授权页面 POST 之后,需要跳回 ChatGPT 的 callback 地址。但我的页面 CSP 一开始写得比较死:

1
Content-Security-Policy: form-action 'self'

这意味着浏览器只允许表单提交到当前站点自己,不能提交到 https://chatgpt.com/... 这样的外部 callback。

结果就是:OAuth 服务端逻辑看起来没问题,redirect_uri 也验证过了,但浏览器层面直接被 CSP 拦住。

修复方式是:

  • html() 的 CSP option 增加 formAction
  • OAuth authorize 页面通过 redirect_uri 提取安全 origin。
  • CSP 从固定的 form-action 'self' 改成类似:
1
form-action 'self' https://chatgpt.com

当然,这里不能随便放开,而是只放行已经验证过的 redirect_uri origin。

这个坑给我的第二个教训是:

OAuth 回调不是纯服务端 redirect 问题。只要有浏览器表单参与,CSP 就会成为真实兼容性边界。

安全策略不是越死越好,而是要按 OAuth 流程精确放行。


5.4 坑三:Claude 的 resource 多了一个尾斜杠

对应 commit:8427a17

这个坑很有意思,因为 ChatGPT 没问题,Claude 有问题。

有客户端会把 resource 发成:

1
https://mcp.example.com/

而我的服务端期望的是:

1
https://mcp.example.com

这两个 URL 在语义上都是同一个 origin,但如果你做字符串严格比较,它们就不相等。结果 authorize 或 token exchange 阶段就会报:

1
2
Invalid resource
invalid_target

修复方式是新增 canonicalResource()

  • 把 resource 规范化成 URL origin。
  • authorize 和 token exchange 都使用 canonical 后的 resource 比较。

这个坑给我的第三个教训是:

OAuth Resource Indicator 的语义通常是 origin/audience,不应该被尾斜杠这种 URL 表达差异击穿。

严格校验当然重要,但应该校验”语义严格”,而不是校验”字符串脆弱相等”。


5.5 坑四:ChatGPT 需要的不只是 OAuth,还需要 MCP tool metadata

对应 commit:6adf9ac

OAuth flow 跑通之后,我以为大功告成了。但实际接 ChatGPT 时发现,能授权不等于能被宿主客户端稳定、舒服地使用。

ChatGPT 还会消费 MCP tool metadata,用来判断 tool link、权限、调用状态文案、结果结构等。只有顶层 securitySchemes 不够稳,工具结果如果没有结构化输出,客户端也不一定能很好解析和展示。

修复方式是:

  • 给每个 tool 增加 _meta.securitySchemes
  • 增加 openai/toolInvocation/invokingopenai/toolInvocation/invoked 文案。
  • 增加 outputSchema,tool 返回增加 structuredContent,create/read/delete 等工具都输出可解析结构。

这个坑给我的第四个教训是:

Remote MCP 的”能授权”不等于”能被客户端好好使用”。

兼容性问题常常不在 OAuth endpoint,而在 MCP tool 描述层。宿主客户端不只是调用工具,也会读取 metadata 来决定怎么展示、怎么解释、怎么让用户确认。


5.6 坑五:没有 refresh token,长会话体验会很差

对应 commit:e183681

第一版 access token 只有 1 小时 TTL。理论上这没问题,短期 token 本来就应该过期。

但真实体验很差。

我在 Codex 或 Claude 里开一个新 session,让 Agent 调 MCP 工具时,经常又要重新输入 passphrase。这个过程多来几次之后就很烦,因为你本来是想让 Agent 帮你自动化,结果自己一直在做认证体力活。

根因有两个:

  1. metadata 里只声明了 authorization_code,没有 refresh_token grant。
  2. OAuth authorize 页面不复用已有站点 session,每次都展示 passphrase 表单。

修复的关键改动:

  • metadata 的 grant_types_supported 增加 refresh_token,scope 增加 offline_access
  • authorization code exchange 返回 refresh_token/oauth/token 支持 grant_type=refresh_token,refresh 时轮换出新的 access token 和 refresh token。
  • OAuth authorize 引入 hasValidSession():已有站点 session 时直接签发 authorization code 并 redirect;passphrase 验证成功后写入同一个站点 session cookie。
  • wrangler.jsonc 里 pin PUBLIC_BASE_URL 到自定义域,避免 workers.dev / 自定义域混用导致 token iss/resource 和请求 origin 不一致。

这里有两个比较重要的判断。

第一,这个项目的真实认证主体其实是”知道站点 passphrase 的我”,不是一个完整的 OAuth 用户体系。所以 OAuth authorize 应该复用站点 session,否则体验会非常割裂。

第二,canonical origin 很重要。token 的 iss/resource 校验很严格,如果客户端连错 hostname,就会出现 token 看起来合法,但 MCP 验证失败的情况。

这个坑给我的第五个教训是:

能跑通一次 OAuth flow 不难,难的是让它在长会话里少打扰用户。


5.7 坑六:安全语义不能永远靠自包含签名值硬撑

这部分跨了后续多个 commit,是持续进行的安全硬化。

一开始为了简单,我用了 HMAC 签名的自包含 client_id、authorization code 和 access token。这种方式实现快、状态少,很适合 demo 和第一版验证。

但它有明显边界:

  • authorization code 如果只靠签名和 TTL,可能在有效期内被重放。
  • refresh token 如果完全自包含,rotation 和吊销会很别扭。
  • consent 状态如果不落库,后续很难审计和管理。
  • 多客户端、多 resource、多 scope 之后,纯自包含 token 会越来越难维护。

所以后续我先把缺口文档化,再用 D1 补上服务端状态:

  • authorization code 单次使用。
  • refresh token rotation。
  • consent 页面和授权记录。
  • 更清晰的客户端注册与授权状态。

这个坑给我的第六个教训是:

自包含 token 可以让第一版很快跑起来,但真正的安全语义迟早需要 server-side state。

尤其是 authorization code、refresh token、consent 这些东西,本质上都不是”签个名就完事”的问题。


6. 整体脉络:这件事到底难在哪里

回头看,这串改动大概分成四层:

  1. 基础能力459288c 实现 OAuth Authorization Code + PKCE + MCP Bearer Token。
  2. 客户端互操作7a4f1cf612c30b8427a176adf9ac 修 ChatGPT / Claude 真实接入时遇到的 HTTP、CSP、resource、tool metadata 问题。
  3. 可持续使用e183681 等改动增加 refresh token 和站点 session 复用,减少重复授权。
  4. 安全硬化:先把缺口文档化,再用 D1 状态补单次 code、refresh token rotation、consent 页面。

最核心的结论是:

MCP OAuth 的难点不只是 OAuth endpoint 正确,而是 HTTP challenge、MCP metadata、token 安全语义三件事必须同时成立。

具体来说:

  • HTTP 层必须发出客户端识别得了的 OAuth challenge。
  • OAuth metadata 必须让客户端能正确发现 authorization/token/registration endpoint。
  • MCP tool metadata 必须符合宿主客户端的读取习惯。
  • token/code 的安全语义需要服务端状态,不能永远靠自包含签名值硬撑。
  • 不同客户端实现细节不同,ChatGPT 能过,不代表 Claude 一定能过。

到这里,这篇文章最核心的故事就讲完了。下面是跑通之后的效果截图。

ChatGPT

ChatGPT 成功授权并调用远程 MCP 工具

Claude Desktop

Claude Desktop 成功授权并调用远程 MCP 工具


7. 这次折腾之后,我对 MCP 的几个判断

7.1 MCP 会成为个人自动化的一个关键入口

以前我们说自动化,更多是脚本、CLI、浏览器插件、本地服务、crontab、Webhook。

MCP 出现之后,你可以把这些能力封装成 MCP tool,让 Agent 用自然语言规划,再通过标准协议调用。

这对个人玩家尤其有吸引力。因为个人电脑里往往有大量碎片化能力:脚本、配置、笔记、项目、下载器、家庭服务器、本地模型、NAS、小工具。如果这些能力都能通过 MCP 暴露给 Agent,就会变成一个很强的私人自动化入口。

当然,前提还是安全。

7.2 Remote MCP 的核心不是”连通”,而是”可信调用”

Cloudflare Tunnel、Tailscale Funnel、反向代理都可以解决连通性问题。

但连通之后,真正的问题才开始:

  • 谁可以调用?调用什么 scope?
  • token 多久过期?refresh token 怎么轮换?
  • authorization code 能不能重放?
  • 不同客户端的 OAuth 实现差异怎么兼容?
  • MCP tool metadata 怎么让宿主客户端正确理解?

这些问题都不是 tunnel 能解决的。

7.3 个人玩家不应该长期手搓 OAuth

这次我手搓 OAuth,主要是为了理解协议边界和打通最小闭环。这个过程很有价值,因为很多坑只有自己实现一遍才会真的理解。

但如果回到长期方案,我不认为”每个 MCP Server 都自己实现一套 OAuth”是最佳选择。

既然 OAuth 的流程规范如此严格和统一,更合理的架构应该是:

1
2
3
4
5
6
7
8
Claude / ChatGPT / Codex
|
v
OAuth Gateway
|
+--> MCP Server A
+--> MCP Server B
+--> MCP Server C

OAuth Gateway 统一处理 OAuth metadata、client registration、authorization code + PKCE、token endpoint、refresh token、consent、session、token 验证和签发;各个 MCP Server 只保留 .well-known 必要端点、token 验证逻辑、tool scope 声明和业务工具实现。

这样才比较适合个人长期维护多个 MCP Server。

Cloudflare Worker 很适合做这件事,甚至 Cloudflare 自己也有和 OAuth 相关的库与实践。后面如果继续折腾,我大概率会把它做成一个独立的 OAuth Gateway,而不是让每个 MCP Server 都重复造轮子。


8. 结语

一番折腾之后,我终于让 Claude、ChatGPT 通过 MCP 连上了家里的 MacBook。

成就感当然是有的。更重要的是,这次实验让我更清楚地看到:MCP 真正有意思的地方,不只是”让模型调用工具”,而是把个人电脑、家庭服务器、本地脚本、私有服务这些原本分散的能力,统一变成 Agent 可以理解和调用的远程能力。

不过,能跑只是第一步。要长期好用,还要继续解决认证、安全、权限、审计、客户端兼容、token 生命周期这些工程问题。

这套 OAuth 实现的代码,我会在整理之后开源;下个月还有一个 Playwright 失败现场打包工具(Failure Packager)也在开源计划里,是我在 AI 测试自动化方向的另一条线。如果你在自建 MCP 时踩到了类似的坑,欢迎留言交流。

这是「跑通为止」系列的第一篇。后面我想继续沿着”个人开发者如何把 Agent 真正接进自己的工具链”这个方向折腾:OAuth Gateway、Remote MCP、Cloudflare Worker、多客户端互操作,以及更稳定的个人自动化工作流。

能不能优雅不一定,但至少先让它能跑起来。