一次跨栈 MCP 接入的开发复盘:从方案澄清到端到端验证

一次跨栈 MCP 接入的开发复盘:从方案澄清到端到端验证

复盘把 PDF 工具箱接入 Agent 的跨栈 MCP 开发过程:从方案澄清、OAuth 2.1 与 PKCE、Supabase 授权事实、Go MCP Gateway 鉴权,到真实 Agent 端到端验证,并总结回调校验、文件输入、幂等等架构坑。

bob
展开文章目录

公司要求:开发基于PDF工具箱的MCP服务。把 PDF 工具箱接入 Agent,看上去像是“给现有接口加一个 MCP 入口”。实际做下来,它更像一次跨越 MCP 协议、OAuth 授权、用户体系、异步任务和多个部署服务 的系统集成。

这次工作的首要产出并不是代码,而是把边界讲清楚:谁负责协议,谁保存授权事实,谁执行业务,客户端带来的文件如何被处理,以及出了问题该从哪里排查。方案清楚后,后续开发和联调才有了可验证的依据。

本文记录这次接入的方案确定过程、最终架构、开发中暴露的问题,以及完整验证为什么必须从浏览器授权一直走到真实工具调用。

1. 背景:为什么这不是一次普通的接口改造

现有系统已经有 Next.js 网站、Supabase Auth、用户 API Key、积分和 PDF 异步任务,也有 Go 侧的转换服务。MCP 接入后,调用方从网页变成了豆包、Claude、ChatGPT 等 Agent 客户端,入口协议和身份链路都发生了变化。

目标不是把旧 API Key 直接暴露给 MCP 客户端,而是同时满足下面几件事:

  • Agent 客户端能按 MCP 的 Streamable HTTP 方式发现并调用工具。
  • 用户在浏览器中完成标准 OAuth Authorization Code + PKCE 授权。
  • 用户、积分、任务、审计和并发控制继续复用现有业务体系。
  • Go 网关可作为多个 MCP 服务的统一入口,PDF 只是第一个服务模块。
  • 手动配置 MCP 的客户端仍可使用长期 API Key,避免频繁手工更新短期 OAuth access token。
  • 真实转换任务、文件下载和错误日志都能追溯到用户与调用凭证。

因此,问题不在于仅仅实现这个MCP功能,而是合理的架构前提下实施,以便之后的扩展和维护。

2. 先与 AI 把方案讨论清楚,而不是先写代码

这次最有价值的工作,是把原本模糊的需求拆成一组可验证的架构决定。AI 在这里适合做资料对照、提出反例、检查跨服务耦合和生成验证清单;最终的协议边界、业务授权和上线策略仍需由人确认。

2.1 方案讨论要回答的核心问题

在开始开发前,至少应明确以下问题。

问题最终结论原因
MCP 服务放在哪里Go MCP Gateway 独立承载Go 更适合承接长连接、协议适配、下游调用和统一日志;未来可挂载多个 MCP 服务。
OAuth 服务放在哪里Next.js 统一提供授权、Token、注册与发现端点可复用网站登录态、用户同意页和品牌域名入口。
授权事实由谁保存Supabase 数据库授权码、Grant、Refresh Token、撤销状态属于持久化业务事实,不能依赖某个 Next 进程。
Gateway 如何验证凭证受限 Supabase RPCGateway 只知道“凭证是否有效及其身份上下文”,不直接读取授权表,也不反向依赖 Next。
文件如何传入工具客户端提供 Gateway 可访问的 HTTPS 临时 URL远程 Streamable HTTP MCP 不应假设能从 Agent UI 直接获得 multipart 文件流;附件映射需要成为明确契约。
是否复用旧 API Key复用用户和计费归属,不复用 OAuth 协议语义OAuth access token、手动 API Key、网页内部 Key 的生命周期和暴露边界不同。

这张表看似简单,却决定了大量实现细节。例如,一旦把授权校验放在 Next 的 HTTP 接口上,Go 网关就会在每次工具调用时依赖前端服务;一旦没有先定义文件输入契约,工具 Schema 写得再漂亮也无法保证 Agent 上传的附件能被真正下载。

2.2 从“需求描述”变成“可验收的方案”

方案确定时,不能只说“支持 OAuth”和“支持 PDF 转 Word”。每项能力都要落到请求、数据和失败行为:

  1. 协议层:MCP 地址、Protected Resource Metadata、Authorization Server Metadata、DCR、PKCE、回调 URI 精确匹配和 Token 刷新。
  2. 身份层:OAuth access token、refresh token、手动 API Key、内部服务凭证分别可做什么,是否可撤销,如何计费归属。
  3. 业务层:转换、拆分、合并、水印、查询任务的输入、输出、异步状态和幂等范围。
  4. 运行层:域名、反向代理、日志目录、跨服务超时、错误记录和部署配置。
  5. 验收层:从发现地址到真实任务结果的完整链路,不能只测某一个 HTTP 接口。

这也是下一阶段应继续做的事:先把每一个新 MCP 服务按这五层补齐方案,再进入编码,而不是复制 PDF 模块后边做边猜。

3. 最终架构:协议、授权和业务各司其职

3.1 系统全景图

图表加载中…

架构的关键不在于服务数量,而在于责任不重叠:

  • Next.js 是面向用户和 OAuth 客户端的协议入口,负责登录态、同意页、授权码、Token、撤销和发现元数据。
  • Supabase 是身份、授权、任务和计费的事实来源。它保存可撤销、可审计的数据,不承担 MCP 协议转发。
  • Go MCP Gateway 是面向 Agent 的资源服务器,负责 Streamable HTTP、鉴权、中间件、工具列表、参数适配和日志。
  • PDF 模块与 converter 只专注 PDF 业务,不需要理解浏览器授权页面或客户端注册细节。

3.2 OAuth 授权与工具调用是两条不同链路

把两条链路分开理解,有助于定位问题。

图表加载中…
图表加载中…

前一条链路的成功不代表后一条链路必然成功。许多联调问题都发生在两者交界处:客户端拿到了 Token,但请求的资源地址、Authorization 头格式、Gateway 元数据或文件 URL 并不符合预期。

3.3 PKCE:把用户同意绑定到同一个 Agent 实例

PKCE(Proof Key for Code Exchange)是这条链路里很巧妙的一层设计。MCP Client 属于公开客户端,不能安全保存传统 OAuth client_secret;而浏览器回调里的授权码又可能被日志、浏览器扩展或恶意软件截获。PKCE 用一份只保留在客户端本地的随机原文,解决了这个问题。

图表加载中…

具体过程分为四步:

  1. Agent 在发起授权前生成随机的 code_verifier 原文,只保存在自己的内存或安全存储中。
  2. Agent 计算 BASE64URL(SHA-256(code_verifier)),把得到的 code_challenge 连同 code_challenge_method=S256 发给授权端点。服务端只保存 challenge,而不保存 verifier 原文。
  3. 用户完成登录和“同意授权”后,授权服务器向登记的回调地址返回一次性授权码。
  4. Agent 用授权码兑换 Token 时,必须交回最初的 code_verifier 原文。服务端重新计算哈希并与先前保存的 challenge 比较;一致才允许兑换。

这意味着,即使有人只截获了浏览器回调中的 code,也无法兑换 Token,因为他没有最初由 Agent 生成的 code_verifier。严格说,PKCE 证明的是“当前兑换者持有发起该授权请求时生成的 verifier”,它不证明某个品牌或某台设备的身份;但对于公开 Agent 客户端,它有效地把用户的授权同意、返回的授权码和同一个客户端实例绑定在一起。正因如此,验证时不能只检查回调有 code,还必须检查 state 并用原始 code_verifier 真正兑换 Token。

4. 数据与凭证模型:复用业务,不混淆语义

4.1 不同凭证必须有明确边界

凭证使用场景生命周期Gateway 接受方式
MCP OAuth access token支持 OAuth 的远程 MCP Client短期,可刷新Authorization: Bearer <token>
MCP refresh token客户端后台续期仅调用 Token 端点,不可调工具不进入 Gateway
用户手动 API Key手工配置、脚本、只支持自定义 Header 的客户端相对长期,可在账户中撤销原始 Authorization: <key> 或 Bearer 形式
网页内部 Key / 服务凭证网站或内部服务调用不对外暴露Gateway 明确拒绝

用户 API Key 与 OAuth access token 可以共享哈希、撤销、api_key_id 计费归属等基础能力,但它们不是同一种协议凭证。把所有 Key 都当成 Bearer Token,或者把 OAuth Token 当成长效 API Key,都会让撤销、续期、审计和客户端体验变得不可靠。

4.2 introspect_mcp_credential 的设计

Gateway 不直接读取 oauth_grantsoauth_refresh_tokens 等表,而是调用仅授权给 service_role 的 RPC:

introspect_mcp_credential(token, resource)
  → active / user_id / client_id / scope / audience / expires_at / api_key_id

这个 RPC 的作用是把内部表结构收敛成网关真正需要的最小身份上下文。它带来三个好处:

  • Go 服务不依赖 Next 进程,不会因前端服务重启或路由变化影响鉴权。
  • 授权表的结构可以演进,Gateway 无需跟着复制 SQL 或表查询约定。
  • 数据库可以只给 service_role 执行权限,匿名和普通登录角色没有调用入口。

性能上还应做到凭证类型先分流:

  • md_oauth_ 前缀仅查询 OAuth access token;查不到时才查询一次旧 access token 的短暂宽限期。
  • 手动 API Key 直接按其哈希查询 api_keys,不会再扫描 OAuth Grant。

这里 OAuth 的第二次查询不是多余开销,而是 Token 轮换后的兼容机制:网络重试或客户端并发请求可能仍携带刚被替换的旧 Token。没有这一小段宽限期,用户会看到偶发的未授权错误;宽限期没有严格过期和撤销校验,又会扩大泄露 Token 的风险。

5. 开发中的架构坑:问题往往不在“功能没写完”

5.1 单文件 Go 服务:能跑,不等于可演进

初版 AI 曾把路由、工具定义、鉴权、converter 调用和辅助方法堆在一个 Go 文件中。对于临时 demo 可以接受,但它与既有 Go Zero 服务的目录和运行方式不一致,也无法支持“一个网关下多个 MCP 服务”的目标。

后续调整为标准分层结构:

mcp_gateway/
├── etc/                 # 服务与日志配置
├── internal/config/     # 配置定义
├── internal/svc/        # ServiceContext
├── internal/middleware/ # MCP 鉴权
├── internal/handler/    # 路由、元数据、工具排序
├── internal/logic/pdf/  # PDF 核心逻辑与辅助逻辑
└── logs/                # access / error / slow / severe / stat

其中 internal/logic/pdf 又拆分了工具定义、转换调用、文件下载和主业务逻辑。这样的拆分不是为了目录好看,而是为了让新增图片、音频或其他 MCP 服务时,不必触碰 PDF 的实现细节。

经验:只要需求里已经写了“未来可能有多个服务”,第一版就应该按可扩展的服务边界落目录,避免 demo 结构成为长期架构债务。

5.2 Gateway 反向请求 Next 鉴权:职责方向错了

初版AI 请求 Next 的 introspect 接口确认 Token。这个设计就搞错了方向,架构混乱:每次 MCP 工具调用都依赖网站前端服务。

这会引入不必要的耦合:

  • Next 部署、重启、缓存或反向代理异常会让 MCP 鉴权失败。
  • Go 需要了解 Next 的内部 Header、接口契约和服务地址。
  • 数据库里已经存在授权状态,却多了一层转发。

后改为 Gateway 直连 Supabase 受限 RPC 后,职责方向恢复正常:Next 负责写入授权事实,Gateway 负责读取授权结论,Supabase 是双方共同依赖的事实来源。

经验:后端服务需要鉴权结论时,应优先依赖权威数据源或专用身份服务;不要因为“前端已有一个接口”就让后端绕过数据库事实去请求前端。

5.3 远程 MCP 的文件输入不是浏览器表单上传

Agent 界面允许用户上传文件,不意味着远程 MCP Server 能直接收到文件。对于目前MCP,反复确认多次,发现在JSON-RPC调用中只支持url,没办法支持文件。这个限制对于开发我们这文件处理MCP是不友好的。实施上是一个功能的削弱。

好在后面使用豆包联调过程中发现,豆包自带文件转url 功能,可以方便传url 到MCP。但这里还是留下了一个潜在的坑。 其他agent如果这方面支持不够的话,需要再开发一个上传文件转url 的skill来作为补充。

经验:设计工具 Schema 前,先确认目标 Agent 对附件的实际传递形式。不要因为本地工具可以用 multipart/form-data,就假设远程 MCP 客户端一定会转发 multipart 文件流。

5.4 工具 Schema 与客户端实现存在兼容性边界

图片转 PDF 的 image_urls 曾因 Schema 中对数组使用可空联合类型而触发客户端 JSON 参数解析失败。最终应使用客户端兼容的明确数组 Schema,并在工具说明中给出输入格式。

另一个细节是,业务服务的文件数量上限来自配置,Gateway 不应再写死一套“1 到 10 个”的重复限制,否则配置调整后 MCP 与业务接口会产生不一致。

经验:MCP 工具 Schema 需要在真实 Agent 中验证。能被 SDK 接受的 Schema,不一定能被所有客户端的工具调用生成器正确构造。

5.5 Migration 也要作为可执行代码审查

开发中曾出现同一函数重复定义、SQL 别名使用保留字导致 db reset 失败等问题。由于本次工作尚未推送远端,最终做法是合并到最后一个未发布 migration,而不是不断新增互相依赖的修复 migration。

数据库改动需要检查:

  • 本地 db reset 或目标环境迁移是否能顺序执行。
  • RPC 是否只授权给需要的角色。
  • 新增约束和索引是否匹配业务幂等边界。
  • 生成的 TypeScript 数据库类型是否同步。

经验:Migration 不是说明文档,而是生产环境将直接执行的程序。它和 Go、Next 代码一样需要可重复执行和最小权限审查。

5.6 幂等不应误伤用户的并发工作

PDF 转 Word、PDF 转 Excel、拆分和合并都是异步任务。幂等键应限定在“同一用户、同一凭证、同一操作、同一幂等键”的范围,而不是简单地禁止用户同时存在任何 MCP 任务。

这样用户可以同时转换两个 Word 文件,或同时执行 Word 与 Excel 转换;同一请求因网络重试而重复提交时,才返回已有任务。

经验:并发控制回答的是“哪些请求不该同时执行”,幂等回答的是“哪一次重试等价于原请求”。两者不能混为一个全局任务锁。

5.7 localhost 回调被过严认证规则拒绝:连接失败不在 MCP 工具

首次联调失败的根因不是 Agent 无法访问本机,而是 OAuth/MCP 接入初期对 redirect_uri 的校验过严:只接受 HTTPS 公网回调,拒绝了 http://localhost:<port>/... 这类本机回调地址。agent一过来注册就失败了,整个授权和 MCP 连接流程因此中断。

后续对豆包、Codex、Cloud 等实际客户端的请求进行比对后发现,它们都会在本机启动回调监听,并把 localhost 作为 OAuth 回调地址。这是原生客户端常用的 OAuth 模式:浏览器仍访问公网授权服务器,但用户同意后,浏览器将一次性 code 重定向给本机运行的 Agent。

定位过程依赖日志,而不是猜测客户端行为。

修复后的规则不是给每个 Agent 维护一份域名白名单,而是按 URI 的安全语义校验:允许任意 HTTPS 回调;同时允许带显式端口的 HTTP loopback 回调,只限 localhost127.0.0.1::1,拒绝普通 HTTP 域名、任意局域网 IP、账号密码字段和片段。客户端注册后,授权请求与 Token 兑换仍必须与登记的 redirect_uri 完全一致。

需要区分两个地址:MCP Gateway 和 OAuth 服务本身仍应使用 Agent 可访问的 HTTPS 公网域名;只有授权完成时返回给本机 Agent 的 redirect_uri 可以是 localhost。

经验:OAuth 回调地址的规则必须按客户端类型设计。对浏览器网站只允许 HTTPS 是合理的;对桌面 Agent 和原生客户端,拒绝 loopback HTTP 会直接破坏标准授权流程。日志中授权请求停在哪一层,是区分回调校验问题、MCP 鉴权问题和工具业务问题的最快方法。

6. 真实对接验证:先用 Python 走 OAuth,再交给真实 Agent

这次验证不把“接口返回 200”当作完成标准,而是分成两个连续的真实过程:Python 验证 OAuth 协议闭环,再由 豆包、Codex 等真实 Agent 连接并调用 MCP。前者验证协议实现,后者验证客户端兼容性与业务能力;两个过程都完成,才说明服务真正可接入。

图表加载中…

6.1 Python 全流程:由用户配合完成浏览器授权

项目中的 scripts/test_mcp_oauth.py 是一个面向真实环境的手工联调程序。它不是伪造 Token 的测试工具,而是按客户端实际行为运行:脚本启动本地回调服务,用户在浏览器中登录并点击同意,回调抵达后脚本再完成授权码兑换。

执行过程如下:

  1. 脚本读取 Authorization Server Metadata 和 Protected Resource Metadata,不把 Token、注册和 resource 地址写死在测试逻辑中。
  2. 脚本通过 DCR 注册一个测试客户端,并登记 HTTPS 回调地址。
  3. 脚本生成 statecode_verifiercode_challenge,打印并打开带 PKCE 参数的授权 URL。
  4. 用户在浏览器完成登录和授权同意;公开回调域名将 /oauth/callback 转发给脚本监听的本机端口。
  5. 脚本收到回调后先验证 state,拒绝缺少授权码或状态不匹配的回调。
  6. 脚本把 code、原始 code_verifierredirect_uriclient_idresource 一并提交到 Token 端点。
  7. 脚本打印 Token 交换结果,随后可使用 refresh token 验证刷新与轮换行为。

示例命令:

python scripts/test_mcp_oauth.py

这里用户参与不能省略:登录、查看同意页、点击同意和浏览器重定向,本身就是 OAuth 产品体验的一部分。脚本自动完成的是客户端应完成的协议动作,不替代用户对授权范围的确认。

Python 流程应明确检查以下结果:

  • 元数据中的 issuer、授权端点、Token 端点、注册端点和 resource 相互一致。
  • DCR 返回有效 client_id,回调 URI 与登记值完全一致。
  • 用户同意后浏览器立即跳转到回调页,脚本收到 code 与原始 state
  • 使用正确 code_verifier 能兑换 Token;篡改 verifier、state 或 redirect URI 时必须失败。
  • refresh token 能换取新 access token;旧 access token 仅在预期宽限期内可用,之后必须失效。

6.2 真实 Agent 验证:连接服务并调用 PDF 能力

Python 验证通过后,才进入 Agent 侧。这里使用豆包、Codex 等目标 MCP Client 添加 Gateway URL,按各客户端提供的交互完成授权。这个阶段验证的是客户端是否真的能理解元数据、构造 OAuth 请求、保存 Token、发送 MCP 消息并生成正确的工具参数。

按以下顺序执行:

  1. 在 Agent 中添加远程 MCP 服务地址,触发授权。
  2. 在浏览器完成登录与同意,回到 Agent 后确认连接状态成功。
  3. 检查 initializeinitializedtools/list,确认工具名称、描述、排序和参数 Schema 正常显示。
  4. 上传真实 PDF 或图片,让 Agent 把附件转换为 Gateway 可访问的 HTTPS 临时 URL,再调用 PDF 转 Word、PDF 转 Excel、拆分、合并或图片转 PDF。
  5. 调用任务查询工具,确认异步任务状态、结果文件、用户隔离、积分归属和幂等行为正确。
  6. 使用过期 Token、已撤销 Key、无效文件 URL 和非法参数复测失败路径,确认 Agent 获得可理解的错误,Gateway 的 error.log 同时有完整记录。

其中 PDF 转 Word 和 PDF 转 Excel 是重点业务验证项,尤其要使用包含表格、图片和公式的真实 PDF,检查输出文档中的公式是否按预期经过专用转换能力处理。图片转 PDF 还要验证多个 image_urls 能被 Agent 生成合法 JSON 数组,而不是在工具参数序列化阶段失败。

最终验收应同时保留两类证据:Python 终端中的发现、注册、回调和 Token 交换记录,以及真实 Agent 的连接成功、工具调用、任务结果和 Gateway 日志。前者证明 OAuth 实现正确,后者证明产品在目标使用环境中真正可用。

7. 结语

这次 MCP 接入最重要的成果不是多出十个 PDF 工具,而是建立了一条清晰的链路:客户端遵循标准协议,Next 承接用户授权,Supabase 保存授权事实,Go Gateway 承接 MCP 调用,converter 专注处理业务

AI 写代码现在越来越强了,当前主要的工作模式是:

  1. 和 AI 讨论实施方案,修改、完善,最终确认方案。
  2. 实施结束后检查实施结果。由于代码要用于生产,此次还是进行了通读代码的检查,估计以后慢慢就不需要了?
  3. 逐功能的联调验证,这个现在是工作的大头。

记录一下当前工作模式,说不定过几个月就变了,哈哈!

最后,欢迎有需要的朋友使用我们的 MCP 服务。说实话,PDF 转 Word 还是相当不错的,尤其是公式的处理。