我把家里的 MacBook 变成了 Claude 的远程工具箱:MCP OAuth 踩坑记
太长不看版:把家里 MacBook 上的 MCP Server 用 Cloudflare Tunnel 暴露到公网之后,让 Claude / ChatGPT 稳定连上它的真正难点不是网络,而是 OAuth。我踩了六个坑:
① 未认证请求必须返回真正的 HTTP 401 +WWW-Authenticate,只在 JSON-RPC 层提示客户端不认;
② 授权页的 CSPform-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 时,就遇到了下面的问题:

这张图背后的意思很直接:客户端发现你的远程 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 不是本文重点,这里只简单带过。
大致流程是:
- 用 Anthropic、OpenAI 或社区 MCP SDK 写一个最小 MCP Server。
- 本地启动,让它监听
127.0.0.1:port。 - 使用 Cloudflare Tunnel 把这个本地端口暴露成一个公网 HTTPS 地址。
- 在 Cloudflare Dashboard 里给自己的域名添加 route,把某个子域名指向这个 tunnel。
- 在 Claude / ChatGPT 里添加远程 MCP Server 地址。
如果只看网络链路,这样就已经打通了:
1 | Claude / ChatGPT |
但是这一步完成之后,不代表你就真的可以用了。
因为现在的问题变成了:你的 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 | 401 Unauthorized |
这一步非常关键。客户端不是靠猜,而是靠这个 challenge 进入 OAuth discovery 流程。
4.3 Client 读取 OAuth metadata
客户端先访问 /.well-known/oauth-protected-resource 拿到授权服务器地址:
1 | { |
再访问 /.well-known/oauth-authorization-server 发现各端点:
1 | { |
4.4 浏览器打开授权页,用户登录并确认授权
1 | GET https://mcp.example.com/oauth/authorize? |
code_challenge 就是 PKCE 的关键:它保证后面拿授权码换 token 的客户端,确实是最初发起授权流程的那个客户端,而不是中途截获 authorization code 的攻击者。
4.5 Client 用 authorization code 换 token,然后调用 MCP
授权成功后,浏览器 redirect 回客户端 callback 地址并带上一次性的 code,客户端再请求 token endpoint:
1 | POST /oauth/token |
服务端返回:
1 | { |
之后客户端每次调用 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.securitySchemes、outputSchema、structuredContent |
“能授权”不等于”好用” |
| 频繁重新输入 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 /mcpStreamable 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 | Invalid resource |
修复方式是新增 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/invoking和openai/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 帮你自动化,结果自己一直在做认证体力活。
根因有两个:
- metadata 里只声明了
authorization_code,没有refresh_tokengrant。 - 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里 pinPUBLIC_BASE_URL到自定义域,避免workers.dev/ 自定义域混用导致 tokeniss/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. 整体脉络:这件事到底难在哪里
回头看,这串改动大概分成四层:
- 基础能力:
459288c实现 OAuth Authorization Code + PKCE + MCP Bearer Token。 - 客户端互操作:
7a4f1cf、612c30b、8427a17、6adf9ac修 ChatGPT / Claude 真实接入时遇到的 HTTP、CSP、resource、tool metadata 问题。 - 可持续使用:
e183681等改动增加 refresh token 和站点 session 复用,减少重复授权。 - 安全硬化:先把缺口文档化,再用 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

Claude Desktop

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 | Claude / ChatGPT / Codex |
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、多客户端互操作,以及更稳定的个人自动化工作流。
能不能优雅不一定,但至少先让它能跑起来。
本文标题:我把家里的 MacBook 变成了 Claude 的远程工具箱:MCP OAuth 踩坑记
文章作者:xinlei
发布时间:2026-07-09
最后更新:2026-07-09
版权声明:The author owns the copyright, please indicate the source reproduced