2680 字
13 分钟
给 MCP hub 加一条 curl 旁路:一次性码换任意工具调用

在 claude.ai 网页里让 Claude 调我自建的 MCP 工具,每次都会在对话里渲染一大坨 tool-call JSON——工具名、参数 schema、完整入参对象。问题是这些入参格式本地文档早就有,那坨 JSON 属于纯重复输出,占屏、费 token、还难看。

想要的东西很简单:让 Claude 改用一条普通 curl -X POST 触发工具。curl 走的是 bash 工具,不会在对话里渲染成工具卡片。

但 curl 需要凭据。这个 MCP hub(一个 Cloudflare Worker,GitHub OAuth 单用户网关,之前那篇给 claude.ai 接本地文件桥记过它的终端服务)所有 /mcp/* 端点都由 @cloudflare/workers-oauth-provider 用 Bearer token 把守。那个 token 握在 connector 手里,Claude 自己拿不到。所以核心矛盾是:怎么在不碰既有 OAuth 流程的前提下,授权一条临时的 curl。

两版被否,逼出真正的约束#

第一版我提议写个本地 CLI,跑一整套 OAuth DCR + PKCE + localhost 回调登录,把整个 MCP JSON-RPC/SSE 协议包起来。被否。反馈一句话点醒:MCP 连接已经被 GitHub SSO 鉴权了,需要授权的只是”单次 HTTP POST”,不是再造一个会话。

第二版我改成复用 OAuth 库的内部机制——createClient() 造个内部 client,completeAuthorization() 合成一个 grant,再内部 exchange。又被否,而且是逐句批注:“不要动原本的 github oauth 流程”、“GITHUB SSO 是完备的”。

两次被否逼出了真正的设计约束,这比任何需求文档都清楚:

  • 既有 GitHub OAuth 一个字都不能动。不碰 github-handler.ts,不碰 OAuthProvider 构造,不复用它的 grant/token 机制。
  • 只加两样:① 一个 MCP 工具,在已鉴权会话里铸一个一次性授权码;② 一个公开端点,凭码兑换一次调用。

关键洞察藏在第二条反馈里:授权上下文来自 claude.ai 机器人自己那次已鉴权的调用,不是让用户重新登录 GitHub。 换句话说,能调 MCP 工具,本身就证明你已经过了 SSO。那就让一个 MCP 工具去铸码——SSO 的完备性天然传导给这个码。

还有一个信息决定了端点形态:claude.ai 的代码执行环境是一台 Ubuntu 机器。Claude 能在上面 curl -X POST ... -d @post.json,直接把已有的本地文件喂进请求体。这才是”重复输出”的真正解法——内容只在文件里出现一次,不再被打进对话。所以端点要 body 干净、能 -d @file,而不是带 MCP JSON-RPC 信封 + SSE 的原生 /mcp/*(那反而更笨重)。

端点形态:REST 全量镜像 MCP 工具#

最终定的形态是每个 MCP 工具对应一条 POST /api/<service>/<tool>,body 直接就是该工具的 arguments:

Terminal window
curl -s -X POST https://mcp.lishuyu.app/api/jobtracker/summary \
-H "Authorization: Bearer $CODE" -d '{}'
# body 从本地文件走,正是当初的动机
curl -s -X POST https://mcp.lishuyu.app/api/blog/submit_post \
-H "Authorization: Bearer $CODE" -d @post.json

响应是纯 JSON({ok:true, service, tool, result}{ok:false, error}),没有 SSE,没有 JSON-RPC 信封。

为什么不复用现成的 /mcp/* 端点、把码当 Bearer 塞进去?因为 MCP 的 Streamable HTTP 传输默认返回 text/event-stream(SSE),而且要先 initialize 拿到 Mcp-Session-Id 才能 tools/call。这套流程用 curl 驱动比 tool-call JSON 还啰嗦。既然 body 从文件走,SSE/session 那套笨重就纯是负担、零收益。REST 干净得多。

一次性码:KV + TTL,不是 OAuth grant#

码的实现刻意做成独立的简单逻辑,跟 OAuth 库彻底解耦——这正是被否两次学到的。它复用的是这个 repo 里 /upload 图片暂存早就在用的模式:一个带 TTL 的 KV 条目。

src/lib/relay-code.ts
const RELAY_CODE_PREFIX = "relay-code:";
const RELAY_CODE_TTL_SECONDS = 120;
export async function mintRelayCode(kv: KVNamespace) {
const code = crypto.randomUUID();
await kv.put(`${RELAY_CODE_PREFIX}${code}`, "1", { expirationTtl: RELAY_CODE_TTL_SECONDS });
return { code, expiresIn: RELAY_CODE_TTL_SECONDS };
}
export async function redeemRelayCode(kv: KVNamespace, code: string) {
if (!code) return false;
const key = `${RELAY_CODE_PREFIX}${code}`;
const existing = await kv.get(key);
if (existing == null) return false;
await kv.delete(key); // 删除动作本身 = 单次使用的执行点
return true;
}

几个数字是查证过的,不是拍脑袋:

  • crypto.randomUUID() 在 Workers 运行时(Web Crypto)返回 RFC 4122 v4 UUID,除去 6 位固定的 version/variant,有 122 位随机。当密钥足够。
  • Cloudflare Workers KV 的 expirationTtl 最小 60 秒,小于 60 秒的过期目标不被支持。我用 120s,合规。
  • 码不需要 payload:这是单用户 app,“存在一个有效码”只可能意味着”那个唯一被允许的用户在 2 分钟内铸造过它”。身份、签名、grant 全都不需要。

redeem 用的是 get-then-delete。这里有个诚实的边界要交代:KV 是最终一致的——写入在铸造它的那个网络节点立即可见,但传播到全球其他节点最多要 ~60 秒;而且 get 完再 delete 不是原子操作,理论上两个并发请求可能都读到同一个码再各自删。对单用户 hub 这个竞态可以忽略:铸码后 Claude 立即 curl,通常命中同一区域,读得到;也不会有第二个人抢。真出现偶发兑换失败,把 TTL 调大即可。这些都写进了 CLAUDE.md 的注释,免得未来自己踩。

redeem-before-dispatch,先鉴权#

兑换端点 handleRestCall 的控制流刻意把鉴权放在最前:

src/lib/relay.ts
// 1. 解析 /api/<service>/<tool>
// 2. 取 Bearer 码 → redeemRelayCode 失败即 401(先鉴权,别泄露 schema)
// 3. 查 REST_DISPATCH[service][tool],无则 404
// 4. body → arguments,用 z.object(shape).parse 校验
// (REST 绕过了 MCP SDK 的自动 zod 校验,必须自己验)
// 5. 有 scope 的做 checkRateLimit/bumpRateLimit(跟 MCP 路径共账)
// 6. run → {ok:true,result} / {ok:false,error}

它拦在 oauth.fetch 之前,跟 /upload/mcp/terminal/ws 一个套路——这些路由自身的凭据是一次性码,不是 OAuth token,所以不能进 OAuth provider。/api/ 前缀跟 OAuth 的 /mcp/* 也不冲突。GitHub OAuth 流程全程没被碰过一根汗毛。

一个刻意的取舍:redeem 发生在路由查找之前,所以一个打错的 tool 名配上有效码,码也会被消耗。我选先鉴权——未鉴权请求不该看到 schema 或路由是否存在。代价是打错要重新铸码,但对铸码近乎免费的 Claude 来说无所谓。

get_authorization_code 这个铸码工具本身镜像成 REST 路由。它是自举入口,只能走 OAuth-gated 的 MCP 会话拿码——否则鸡生蛋,一个泄露的码就能自我续期。

DRY:两条路径共用一份实现#

最容易写坏的地方是让 REST 路径把每个工具的逻辑重抄一遍。我读了三个 service 的源码后发现根本不用抄:jobtracker 的逻辑全在 JobTrackerStore DO,terminal 全在 TerminalBridge DO,只有 blog 有真正的内联逻辑。

所以每个 service 导出一张 *_REST_TOOLS 表,run 直接复用既有逻辑:

src/services/jobtracker/mcp.ts
export const JOBTRACKER_REST_TOOLS: Record<string, RestEntry> = {
summary: { ...READ, run: (env) => jobtrackerSummary(env) },
add_application: {
...WRITE,
shape: addApplicationInputShape,
run: (env, _p, args) => getStore(env).insertApplication(args as unknown as ApplicationInput),
},
// … 其余 15 条同样是一行 store 调用
};

getStore(env) / getBridge(env) 是新导出的、类方法现在也委托给它们的 helper——两条路径拿到的是同一个 singleton DO。blog 是唯一有内联逻辑的,把 readPost/listPosts/submitPost 抽成模块级导出函数(this.env→形参 envthis.props!→形参 props),类方法改为委托,MCP 路径行为逐字不变。限流也复用同一批 scope,所以 REST 调用跟 MCP 调用共用每小时预算,不是另开一个绕过口子。

这里有个循环依赖要拆:relay.ts 要 import 三个 service 的 mcp.ts,而 mcp.ts 又要用铸码逻辑。解法是把码的底层原语放进一个无 service 依赖的叶子模块 relay-code.tsmcp.ts 只 import 叶子,relay.ts import 叶子 + 三个 service。叶子不回指任何人,环就断了。

验证:本地端到端 + 多 agent 审查 + 线上确认#

本仓库没有测试套件,唯一的静态验证是 pnpm type-check。但光 type-check 不够,我要跑通真实的 redeem→分发→执行链路。

wrangler dev 起本地 Worker,然后往本地 KV 手动塞一个码——wrangler kvwrangler dev 共用 .wrangler/state 本地状态:

Terminal window
NS=<local-kv-namespace-id>
npx wrangler kv key put "relay-code:LOCALTEST1" "1" --namespace-id=$NS --local

然后一串 curl 验证各条路径。happy path + 单次使用:

== 有效码 → jobtracker/summary ==
{ "ok": true, "service": "jobtracker", "tool": "summary",
"result": { "counts": {...}, "recent_activity": [], "todos": [] } }
HTTP 200
== 同码重放 → 401 单次使用 ==
{ "ok": false, "error": "Invalid, expired, or already-used authorization code. …" }
HTTP 401

写入 + 读回,验证 REST 路径和 MCP 路径共享同一个 singleton store:add_application 插入一行、list_applications 读回,布尔字段正确回转。校验失败走 zod:缺必填字段 → HTTP 400 ZodError。未知工具 → HTTP 404。terminal 无 agent 连接时 → terminal_status 返回 online:false。无 Bearer → 401。全绿。

代码没问题后,跑了一轮多 agent 对抗审查——4 个维度并行红队(correctness / security / consistency-DRY / regression),每条 finding 再由独立 agent 逆向复核,默认 REJECTED 除非能构造出具体失败路径。这套方法在终端 MCP 那篇四轮打穿过我的安全模型,这次结果是:

workflow result
{ "counts": { "confirmed": 0, "plausible": 0, "rejected": 2, "raw": 2 } }

两条 finding 全部被复核推翻,0 confirmed。这次没找到真漏洞,跟上次四轮血流成河的对比恰好说明:结构简单、复用既有模式、不碰鉴权面,攻击面就小。

main,Cloudflare Workers Builds 自动部署(这个 repo 靠 push-to-main 部署,没有 GitHub Actions)。轮询 /version 指纹确认上线,再打线上:

$ curl -X POST https://mcp.lishuyu.app/api/jobtracker/summary
{ "ok": false, "error": "Missing 'Authorization: Bearer <code>' header." }
HTTP 401

线上 REST 镜像生效、且确实拦在 OAuth 之前。

三条经验#

一、被否两次否出了真需求。 我前两版都在”造新会话”和”复用 OAuth 内部机制”上使劲,都被打回。真正的约束是”既有 SSO 完备,别动它”——一旦接受这条,方案立刻收敛成”一个铸码工具 + 一个兑换端点”这种最小旁路。用户说某个系统”完备/不要动”时,正确的反应是加一层最小并行面,而不是去改造或复用它的内部。

二、别造新机制,复用 repo 里已有的模式。 一次性码没上签名、没碰 OAuth grant,就是抄 /upload 那套”KV + TTL”。单用户场景下,“有一个有效码”本身就承载了全部语义,不需要更多。最简单的够用方案往往就是把已经验证过的模式再用一次。

三、DRY 的关键是先读源码看逻辑在哪。 读完发现 jobtracker/terminal 的逻辑早就下沉在 DO 里,REST 路径直接调 DO 就行,只有 blog 需要抽函数。要是没读源码、默认每个工具都得重写一遍,就会凭空多出一大坨重复代码和它们各自的 bug。镜像一个工具,现在只要往 *_REST_TOOLS 表加一条 RestEntry——不用碰兑换端点本身。

给 MCP hub 加一条 curl 旁路:一次性码换任意工具调用
https://blog.lishuyu.app/posts/mcp-hub-rest镜像-一次性授权码/
作者
猫猫魔女
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0