Skip to main content
Glama
958877748

cloudflare-html2sprite-mcp

by 958877748

RemoteDesktopCloud

ChatGPT 与 DesktopCommander 设备之间的中转站(MCP Relay)。

ChatGPT 连接器 ──HTTPS/Streamable──▶ 本 Worker(/mcp) ──Postgres 任务表──▶ Supabase
                                                                    ▲    │
                                            Realtime broadcast ◀───┘    │
npx @wonderwhy-er/desktop-commander remote ◀────────────────────────────┘
        (设备端,官方现成 npm 包,只改 URL,不 fork 不改代码)
  • ChatGPT 通过 OAuth 登录本服务,看到 29 个 DesktopCommander 工具。

  • 调用落到 Supabase mcp_remote_calls 表,Worker 广播 new_call 门铃。

  • 设备端认领任务、执行、写回结果,Worker 轮询返回给 ChatGPT。

  • 单用户、2-3 台设备,免费方案(Cloudflare Free + Supabase Free,¥0)。

状态:MVP 六步全部完成 ✅。真实 ChatGPT 连接器已跑通——ChatGPT 自动发现并使用了本服务发布的 OAuth 端点与作用域,list_devices / start_process 均真机往返成功;四套件 128 项断言全绿(控制台 34 + OAuth 54 + 设备配对 14 + 真机转发 26)。线上地址 https://remotedesktopcloud.txdygl.workers.dev。关键决策见 §8,进度见 §9,已知限制(Workers Free 子请求预算、设备写回假阴性)见 §11。


1. 技术栈与分工

组件

选型

职责

Cloudflare Workers

@cloudflare/workers-oauth-provider + agents/mcp/server

MCP 端点、OAuth 2.1 + DCR、设备授权页

Supabase Free

Postgres + GoTrue + Realtime + REST

任务表、设备表、身份、广播、清扫

设备端

官方 @wonderwhy-er/desktop-commander

现成 npm 包,零改动,仅注入 URL

放弃的路线:B(自研 Supabase 兼容层)、C(自托管)。

两个注入点(不 fork 设备端的全部依据)

  1. MCP_SERVER_URL 环境变量 → 客户端向 GET /api/mcp-info、POST /device/start、POST /device/poll 发请求。

  2. /api/mcp-info 返回 supabaseUrl + supabasePublishableKey(sb_publishable_...)→ 客户端用它 createClient() 连我们指定的 Supabase。


Related MCP server: project-planner-mcp

2. 端点清单

ChatGPT 硬性要求(✅ 已实现,scripts/test-oauth-flow.mjs 54/54 通过)

端点

规范

说明

POST /mcp

Streamable HTTP

公网 HTTPS,MCP 传输

GET /.well-known/oauth-protected-resource

RFC 9728

资源元数据(/mcp 后缀变体另由 Worker 别名补上,两条都 200)

GET /.well-known/oauth-authorization-server

RFC 8414

授权服务器元数据

GET /authorize

OAuth 2.1 + PKCE S256

redirect_uri 必须允许 https://chatgpt.com/connector_platform_oauth_redirect

POST /token

OAuth 2.1

发 token

POST /register

RFC 7591 DCR

必须接受任意 UUID client_id(未注册过的 client_id 走 /authorize 的 KV 兜底补记录)

现成组件:@cloudflare/workers-oauth-provider 覆盖 OAuth 2.1 + DCR + RFC 8414/9728;createMcpHandler(agents/mcp/server)覆盖 Streamable HTTP。 /authorize 是唯一自己实现的 OAuth 端点(provider 只内建 discovery / token / register / API 鉴权),用它的 OAuthHelpers 完成 parseAuthRequest → 密码页 → completeAuthorization。

设备端注入点(✅ 已实现并真机验证)

端点

说明

GET /api/mcp-info

返回 supabaseUrl、supabasePublishableKey、mcpServerUrl、version

POST /device/start

收 S256 code_challenge,返回 device_code + user_code(XXXX-XXXX) + verification_uri(_complete) + expires_in:600 + interval:5

GET /device/verify

密码 + 配对码页面(乙方案,同 /authorize 共用密码校验)

POST /device/verify

校验 AUTH_PASSWORD 与配对码 → 建 mcp_devices 行 → 置 approved

POST /device/poll

pending → {error:'authorization_pending'}(400);批准 → 校验 PKCE → GoTrue password grant → 返回 token + device_id(码用后即焚)

⚠️ 关键约束:/device/poll 的 token 若非 GoTrue 签发,设备端 client.auth.setSession() 直接失败 → 设备授权流必须与 GoTrue 桥接。

控制台(✅ Step 6 已实现,scripts/test-console.mjs 34/34 通过)

端点

说明

GET /

控制台首页:无 session → 密码登录页;有 session → 设备面板(列表 / 在线状态 / 接入命令 / 连接器 URL)

POST /login

校验 AUTH_PASSWORD → 在 OAUTH_KV 写 console:<token>(7 天 TTL)→ 下 rdc_console cookie(HttpOnly + SameSite=Lax)→ 303 /

POST /logout

删 KV session + 清 cookie → 303 /

GET /api/devices

JSON:total / online / devices / html(列表片段由服务端生成,前端只做 innerHTML,避免两套模板)。需 session,否则 401

POST /api/devices/revoke

删 mcp_devices 行(mcp_remote_calls.device_id 是 on delete cascade,调用行一起消失,新调用又因外键插不进去)。需 session + JSON body(跨站 HTML 表单发不出 JSON,作为 CSRF 第二道防线)

GET /api/info

原来挂在 / 上的自描述 JSON,首页被控制台接管后挪到这里

首页原先是自描述 JSON,Step 6 改为控制台;机器可读的那份留在 GET /api/info。


3. 数据模型(Supabase)

唯一事实来源:supabase/migrations/0001_init.sql(已应用到 zdtxqyonglqnyrwayins)。

mcp_devices(
  id            uuid pk default gen_random_uuid()
  user_id       uuid not null default auth.uid() → auth.users(id)
  device_name   text not null default 'device'
  capabilities  jsonb not null default '{}'
  status        text not null default 'offline'
  last_seen     timestamptz not null default now()
  created_at    timestamptz not null default now()
)

mcp_remote_calls(
  id            uuid pk default gen_random_uuid()
  user_id       uuid not null default auth.uid() → auth.users(id)
  device_id     uuid not null → mcp_devices(id)
  tool_name     text not null
  tool_args     jsonb not null default '{}'
  metadata      jsonb not null default '{}'
  status        text not null default 'pending'   -- pending|executing|completed|failed
  result        jsonb
  error_message text
  created_at    timestamptz not null default now()
  completed_at  timestamptz
  timeout_at    timestamptz not null default now() + interval '5 minutes'
)

索引:mcp_remote_calls_claim_idx(device_id, created_at where status='pending',认领用)、 mcp_remote_calls_sweep_idx(清扫用)、mcp_remote_calls_user_idx、mcp_devices_user_id_idx、mcp_devices_last_seen_idx。

RLS(7 条策略,均已实测存在于 pg_policies)

表

策略

角色

规则

mcp_devices

select/insert/update own

authenticated

user_id = auth.uid()

mcp_remote_calls

select/update own

authenticated

user_id = auth.uid()(pending+timeout 由调用方 WHERE 保证 exactly-once)

realtime.messages

select/insert own topic

authenticated

topic = 'user:' || auth.uid() ← 防 CHANNEL_ERROR

Worker 侧走 service_role(bypass RLS),不受上表约束。 realtime.messages 已加入 supabase_realtime publication(分区表,pg_publication_tables 查不到,需查 pg_publication_rel)。

状态机与 exactly-once

pending ──(设备条件 UPDATE 认领)──▶ executing ──▶ completed | failed
  • 认领:UPDATE ... WHERE status='pending' AND timeout_at > now() → 天然 exactly-once。

  • timeout_at = created_at + 5min。

  • 终态行 1 分钟后可删,创建时间 1 小时兜底(清扫)。

  • private broadcast 必须配 RLS 策略,否则客户端 CHANNEL_ERROR。

心跳与清扫档位

有能力 transport_broadcast_v1

无能力

心跳

5 min

15 s

服务端清扫档位

15 min

45 s

token 45 分钟自刷新;capabilities = { app_version, transport_broadcast_v1?: true }。

清扫函数 public.sweep_remote_calls()(已建,实测可执行)

  1. 过期 pending(timeout_at < now())→ failed + error_message='timeout...'

  2. 终态行 completed_at < now()-1min → 删除

  3. created_at < now()-1h → 兜底删除

  4. 设备 last_seen 超档位 → status='offline'(15min / 45s 两档)

调用方式:select public.sweep_remote_calls()(security definer,由 Worker cron 或 pg_cron 触发)。

Realtime

  • 私有频道:user:${user_id},presence key = deviceId。

  • 广播事件 new_call,载荷只有 {call_id, device_id}。

  • 服务端发广播走 REST:POST https://{ref}.supabase.co/realtime/v1/api/broadcast,body {messages:[{topic,event,payload,private:true}]}。

    • 必须同时带 apikey 和「用户」的 GoTrue JWT(Authorization: Bearer <access_token>)。

    • 实测坑:只带 apikey 会回 202 但消息被静默丢弃——私有频道要写 realtime.messages,过 RLS topic = 'user:' || auth.uid(),没有用户 JWT 时 auth.uid() 为空、策略不放行,而接口照样回 202。用 scripts/probe-broadcast.mjs real-apikey / real-jwt 可复现(前者 20s 后仍是 pending,后者 completed)。

    • 旧笔记「带空 Authorization 会 500」(supabase/supabase-js#1936)说的是空串,与上面这条不冲突。

    • 因此 src/calls.ts 没有降级到只带 apikey 的分支:JWT 拿不到就立刻报错,不让 ChatGPT 白等 4 分钟超时。


4. 核心时序

时序 A:设备配对

设备 ─▶ GET  /api/mcp-info          拿到 supabaseUrl + publishable key
设备 ─▶ POST /device/start          拿到 verification_uri + user_code
用户 ─▶ GET  /device/verify         浏览器输密码 + 确认码(服务端代为换取 GoTrue session)
设备 ─▶ POST /device/poll           拿到 GoTrue access/refresh token
设备 ─▶ 写 mcp_devices 行,连 Realtime 频道 user:{id},开始心跳

时序 B:一次工具调用(✅ Step 4 已实现,src/calls.ts)

ChatGPT ─▶ POST /mcp (tools/call)
Worker  ─▶ 选一台在线设备(listDevices,按 last_seen 倒序)
Worker  ─▶ INSERT mcp_remote_calls (status=pending, timeout_at=+5min)
Worker  ─▶ POST realtime/v1/api/broadcast  event=new_call {call_id, device_id}
Worker  ─▶ 轮询该行(自适应退避 500ms→1s→5s,34 轮子请求预算 ≈ 2 分钟,见 §11 第 2 条)
设备    ─▶ 条件 UPDATE 认领 → executing → 本地执行 → 写回 completed/failed
Worker  ─▶ 返回 result(或 isError + 原因)给 ChatGPT

要点:

  • 投递前先挑设备:没有 online 的设备立刻报错并列出各设备状态,不浪费 5 分钟。

  • 定向投递:_meta.device_id 可指定机器(不存在/不在线也立刻报错);缺省用最近活跃的那台。

  • 等不到就明说:超时 / 广播失败 / 轮询 REST 连挂 5 次,都返回 isError 文字原因(按 MCP 规范,工具执行失败要让模型读到原因,而不是抛 JSON-RPC error)。

  • 清扫兜底:Worker 没挂 cron,每次投递前节流(≥10 分钟一次)执行 purgeRemoteCalls(),等价于 sweep_remote_calls() 里跟调用表有关的两条。


5. 工具清单(✅ 已静态注册 29/29,且全部接通转发)

ChatGPT 端看到的全部工具 = server.ts 的 25 个(get_prompts 不暴露)+ 4 个 remote 专属。 inputSchema 零手抄:scripts/capture-tools.mjs 直接把官方 npm 包当 stdio server 跑起来、抓 tools/list 原样写进 tools.captured.json,src/tools.ts 过滤掉 get_prompts 后再补 4 个 remote 专属定义,最后按 name 排序返回。

读取(16) get_config get_file_info get_more_search_results get_recent_tool_calls get_usage_stats list_devices list_directory list_processes list_searches list_sessions ping read_file read_multiple_files read_process_output start_search who_am_i

写入(13) create_directory edit_block force_terminate give_feedback_to_desktop_commander interact_with_process kill_process move_file set_config_value shutdown start_process stop_search write_file write_pdf

备注:

  • list_devices ping who_am_i shutdown 为 remote 专属,device.ts 对 ping/shutdown 有设备侧特判。

  • track_ui_event 只有 switch case、无工具定义 → 不暴露。

  • get_prompts 有定义但 remote 模式不展示 → 不暴露。


6. 环境变量 / 密钥

变量

说明

SUPABASE_URL

https://zdtxqyonglqnyrwayins.supabase.co

SUPABASE_PUBLISHABLE_KEY

sb_publishable_...,经 /api/mcp-info 下发

SUPABASE_SERVICE_ROLE_KEY

sb_secret_...,服务端写表、清扫(仅 Worker 持有)

DATABASE_URL

Postgres 直连串,仅本地跑迁移用,不配进 Worker

SUPABASE_JWT_SECRET / GoTrue 配置

校验设备 token、桥接授权流

OAUTH_SIGNING_KEY

@cloudflare/workers-oauth-provider 签名

AUTH_PASSWORD

/device/verify 与 /authorize 的密码,同时是 GoTrue 唯一用户的密码(乙方案,见 §8)

USER_EMAIL

GoTrue 唯一用户邮箱,/device/poll 用 password grant 换真 token

USER_ID

唯一用户的 UUID(56d074bb-5421-45c1-aac3-600f2cb790e7),乙方案下硬编码进 OAuth token

真实值只存本地 .env(已 gitignore),仓库里只有 .env.example 模板。


7. 仓库现状与改造清单(✅ 已完成)

原为另一项目 personal-agent-mcp(cloudflare-html2sprite-mcp),已推倒重来:

保留

  • src/index.ts 的 import { createMcpHandler } from "agents/mcp/server" 骨架(Step 3 已填上 29 个工具)

  • wrangler.jsonc(nodejs_compat、compatibility_date),name 改为 remotedesktopcloud;Step 3 加了 OAUTH_KV 绑定

已删除

  • src/runtime/{registry,session,transport,durable-object}.ts(死代码)

  • wrangler.toml —— 与 wrangler.jsonc 双配置冲突,wrangler.jsonc 优先导致 env.RUNTIME_SESSIONS 为 undefined,旧版三个工具全挂

已重写

  • 业务逻辑全部重写为 src/{env,supabase,device-auth,index}.ts

  • package.json 改名 remotedesktopcloud,新增 @modelcontextprotocol/server@^2(createMcpHandler 期望的 McpServer 来自它,不是 @modelcontextprotocol/sdk)


8. 已定决策

#

问题

结论

1

身份贯通

乙:环境变量简单密码。/authorize 展示一个密码输入页,校验 AUTH_PASSWORD,通过后签发 OAuth token 并硬编码 USER_ID。最快落地,单用户场景够用;后续要正统登录再升级到甲方案

2

现有代码

保留 wrangler.jsonc + agents/mcp/server import,删 src/runtime/ 与 wrangler.toml,业务全重写

3

开发顺序

倒着做,由本 README 定为:先设备侧(/api/mcp-info + `/device/start


9. MVP 清单(顺序已定,倒着做)

#

步骤

状态

1

schema + RLS + 清扫(sweep)

✅ 已完成(0001_init.sql + 0002_device_codes.sql 已应用并反查验证)

2

设备授权流(/api/mcp-info + /device/start|verify|poll + GoTrue 桥接)→ 先让设备连上

✅ 已完成,真机跑通(官方 npm 包 → 本地 Worker → Supabase,Channel subscribed + Presence tracked + online)

3

/mcp + OAuth(RFC 8414/9728/7591、PKCE、29 工具静态注册)→ 再让 ChatGPT 能调

✅ 已完成(本地全链路 54/54 通过:401→discovery→DCR→authorize→token→initialize→tools/list=29)

4

核心链路(落库 → 广播 → 等结果 → 返回)

✅ 已完成,真机跑通(scripts/test-remote-call.mjs 26/26:ping / get_usage_stats / read_file 真执行,失败与定向投递正确,另有 34.6s 长任务回归)

5

端到端联调

✅ 已完成,真实 ChatGPT 连接器跑通(discovery 端点与作用域均为本服务发布的、list_devices 直答、start_process 真机穿越;四套件 128 项断言全绿)

6

控制台页面(设备列表 / 在线状态 / 一键复制接入命令)

✅ 已完成(src/console.ts,scripts/test-console.mjs 34/34,已部署)

Step 1 交付物(已完成)

  • supabase/migrations/0001_init.sql — 建表 / 索引 / 7 条 RLS 策略 / Realtime publication / sweep_remote_calls()

  • scripts/migrate.mjs — 迁移执行器(读 .env 的 DATABASE_URL,顺序执行、记录 schema_migrations、失败回滚)

  • .gitignore + .env.example — 密钥不入库

  • 验证方式:反查 information_schema / pg_indexes / pg_policies / pg_publication_rel / pg_proc,并实跑一次 sweep_remote_calls()

Step 2 交付物(已完成)

  • supabase/migrations/0002_device_codes.sql — 配对码表(RLS 开启、零策略 = 只有 Worker 可见)

  • src/env.ts — 环境变量定义与校验

  • src/supabase.ts — REST/GoTrue 最小封装(service_role 建表行、password grant 签发 token)

  • src/device-auth.ts — 四个端点 + 验证页(PKCE S256、常量时间密码比较、码用后即焚)

  • src/index.ts — 路由;旧 src/runtime/* 与 wrangler.toml 已删

  • scripts/test-device-flow.mjs — 14 项自测(14/14 通过)

  • 真机验证:MCP_SERVER_URL=http://localhost:8787 npx @wonderwhy-er/desktop-commander@latest remote → 设备 online,capabilities = {app_version:0.2.52, transport_broadcast_v1:true}

踩过的坑(已在代码中修掉)

  1. verify 与 poll 各自建行 → 一次配对产生 2 个设备行。改为 poll 优先读 verify 写入的 approved_device_id,并给 verify 加幂等。

  2. @modelcontextprotocol/sdk 的 McpServer 与 agents/mcp/server 期望的类型不兼容 → 改用 @modelcontextprotocol/server。

  3. 本机 crypto.subtle.digest 无同步 API → verifyPkce 改 async。

  4. 客户端有持久化的旧 session/设备 id(Invalid Refresh Token 属预期),会走完整重新配对流程——服务端必须容忍并复用仍存在的设备行。

明确不做:控制台网页、账单、遥测、feature flags、多租户(数据直接用 Supabase Studio 看)。

Step 3 交付物(已完成)

  • scripts/capture-tools.mjs + tools.captured.json — 从官方 npm 包的 dist 抓 26 个工具的 name/description/inputSchema/annotations/_meta(重跑即可同步设备端)

  • src/tools.ts — 29 个工具注册表 = 抓来的 25(滤掉 get_prompts)+ 4 个 remote 专属

  • src/mcp.ts — 低层 Server + setRequestHandler('tools/list'|'tools/call');who_am_i / list_devices 云端直答,其余 27 个转交 src/calls.ts(Step 4 接通)

  • src/oauth.ts — OAuthProvider 按 origin 惰性构造并缓存;/authorize 密码页(parseAuthRequest → 校验 AUTH_PASSWORD → completeAuthorization,props 写入 userId/email)

  • src/index.ts — 改为 provider 出口:先做 PRM /mcp 后缀别名与 token resource 归一化,再交给 OAuthProvider.fetch

  • wrangler.jsonc — 新增 OAUTH_KV 绑定(真 id 46ade2b57ca8477f8b40669516398c0d,已 wrangler kv namespace create OAUTH_KV 建好;要重建就重跑这条命令再把 id 填回来)

  • scripts/test-oauth-flow.mjs — 12 节 54 项断言(Step 4 把「占位」改成「真转发」多加 1 项),54/54 通过(Step 2 的 14 项回归也照旧全绿)

踩过的坑(已在代码中修掉)

  1. resourceMetadata.resource 必须是绝对 URI,而本地 http://localhost:8787 与线上 workers.dev 不同 → provider 不能写死,改为按 origin 惰性构造 + Map 缓存。

  2. RFC 9728 的 /.well-known/oauth-protected-resource/mcp 变体 provider 只服务「配置的那个精确路径」,另一个会 404 → 在交给 provider 之前先重写到规范路径,两条都能 200。

  3. ChatGPT 可能把 resource 报成 origin/mcp,而我们配置的是 origin(与原版一致)→ /authorize 的 query 与 /token 的 form body 两处都归一到 origin,否则 invalid_target。

  4. 未注册的 UUID client_id 会让 parseAuthRequest 直接 invalid_request → 在解析前按请求的 redirect_uri(仅接受 https / loopback)补一条 client:<id> 记录。

  5. McpServer.registerTool 只收 StandardSchema(zod),而我们是抓来的原生 JSON Schema → 改用低层 Server 的 setRequestHandler('tools/list', …) 原样透传,不做 JSON↔zod 转换。

Step 4 交付物(已完成)

  • src/calls.ts — 核心链路:挑设备 → INSERT mcp_remote_calls → 广播 new_call → 500ms 轮询到终态 → 还原 CallToolResult。含 NUL 清洗(jsonb/text 都存不了 U+0000)、GoTrue session 复用、清扫节流。

  • src/supabase.ts — 新增 insertRemoteCall(Prefer: return=representation 拿 DB 时钟的 timeout_at)/ getRemoteCall / purgeRemoteCalls

  • src/mcp.ts — Result 类型换成真正的 CallToolResult;tools/call 取出 _meta,27 个工具全部转交 dispatchCall

  • scripts/test-remote-call.mjs — 端到端 26 项断言(26/26 通过;原 21 项,Step 5 追加第 8 节 34.6s 长任务的子请求预算回归)

  • scripts/probe-broadcast.mjs — 广播送达探针(real-apikey / real-jwt 两档对照,用来证明「202 ≠ 送达」)

  • scripts/test-oauth-flow.mjs — 第 10 节由「占位」断言改为「真转发」断言,54/54 通过;test-device-flow 14/14 回归也全绿

  • package.json — 新增 npm run test:call

真机验证结果

用例

结果

ping

pong 2026-10-07T02:39:59.471Z(设备侧特判,全链路 3.8s)

get_usage_stats / read_file

设备上真实执行,内容原样回传

不存在的工具名

设备端 Unknown tool → 还原成 isError

_meta.device_id 指向不存在的设备

488ms 立刻报错(不等超时)

_meta.device_id 正确

照常 pong

踩过的坑(已在代码中修掉)

  1. 私有频道广播 202 ≠ 送达:只带 apikey 时 Realtime 照样回 202,但消息被 RLS 静默丢弃,设备永远收不到 → 必须带用户的 GoTrue JWT。原来 README 记的「只能带 apikey」是错的,已订正(§3)。

  2. 客户端参数里可能有 NUL → 整条 INSERT 报 22P05 → 写入前 stripNul() 递归清洗(设备侧写回时做同样的事)。

  3. 设备不在线时如果照常投递,会白等满 5 分钟才超时 → 挑设备这一步先判 status='online',立刻给模型可行动的错误信息。

  4. Worker 没有 cron,mcp_remote_calls 的终态行会堆积 → 每次投递前节流跑一次 purgeRemoteCalls()。

Step 5 交付物(✅ 已完成:真实 ChatGPT 连接器跑通)

线上地址:https://remotedesktopcloud.txdygl.workers.dev (最新 Version 145638bf,bundle 994.96 KiB / gzip 206.50 KiB)

已完成

  1. 清掉 Step 2 自测留下的 5 条 selftest-host 孤儿设备行与 6 条终态调用行。

  2. wrangler secret put × 6:SUPABASE_URL / SUPABASE_PUBLISHABLE_KEY / SUPABASE_SERVICE_ROLE_KEY / AUTH_PASSWORD / USER_ID / USER_EMAIL(.dev.vars 同源,未入库)。

  3. npm run deploy 成功,KV 绑定读的是真 namespace 46ade2b57ca8477f8b40669516398c0d。

  4. 设备端改指生产地址重新拉起 —— 复用持久化 session,无需重新配对(Session restored → Channel subscribed → Presence tracked → online)。

  5. 四套件全绿(128 项断言;建议直接对 http://localhost:8787 跑,见坑 3):

套件

命令

结果

控制台页面

node scripts/test-console.mjs <地址>

34/34

ChatGPT 侧 OAuth + MCP

node scripts/test-oauth-flow.mjs <地址>

54/54

设备配对流

node scripts/test-device-flow.mjs <地址>

14/14

真机工具转发(含长任务)

node scripts/test-remote-call.mjs <地址>

26/26

  1. 真实 ChatGPT 连接器(Step 5 的最后一环,也是 §11 第 1 条原判的"最大风险"):

    • 连接器对话框选 OAuth,「OAuth 高级设置」自动发现出的 Auth / 令牌 / 注册 URL 就是我们发布的 /authorize、/token、/register,授权服务器基础与资源都是本 Worker origin;

    • 默认作用域列出 mcp:tools / desktop:auth —— 这两个值只存在于我们 AS metadata 的 scopes_supported 里,不可能是 ChatGPT 的默认值,证明 RFC 9728 → 8414 discovery 真读了我们的 JSON;

    • 注册方法自动落到 DCR;CIMD 不可用、OIDC 未启用 两条警告都是回退到我们没实现的分支,无影响;

    • 授权后 list_devices 云端直答 Mac / online / v0.2.52;start_process 真机穿越成功(DB metadata 里带 openai/session、openai/userAgent、openai/userLocation 及坐标)。

真机发现并修掉的两个问题(详见 §11 第 2、7 条)

  1. 设备写回的假阴性 —— ChatGPT 那次 ping 在 DB 里是 status=failed,但 result 躺着完整的 ping 输出。设备端「UPDATE 已提交、HTTP 响应丢失」时误判失败并补写 failed。calls.ts 现在以「failed 且有 result」→ 以 result 为准。

  2. 子请求预算 —— Workers Free 单次请求 50 个子请求,固定 500ms 轮询约 40 轮就撞 Too many subrequests(真机复现:一次 read_file 拖到 39s 翻车)。改成自适应退避 + 34 轮预算,34.6s 长任务回归通过。

踩过的坑

  1. 本机直连 workers.dev 不通(curl 20s 超时),必须走 http://127.0.0.1:7897。Node 的 fetch 默认不读 http_proxy,要加 NODE_USE_ENV_PROXY=1(Node 24+)才能让自测脚本和设备端都走代理:

    NODE_USE_ENV_PROXY=1 https_proxy=http://127.0.0.1:7897 http_proxy=http://127.0.0.1:7897 \
      npx @wonderwhy-er/desktop-commander@latest remote

    这只影响本机的网络环境;ChatGPT 从 OpenAI 侧访问 workers.dev 不需要代理。

  2. wrangler secret put 第一次执行会顺带把 Worker 注册出来(Creating new Worker),属于正常输出。

  3. 本机代理在突发请求下会断连 —— 自测脚本偶发 TypeError: terminated / SocketError: other side closed,这是测试客户端的问题,不是服务端。两条出路:重跑,或者直接对 http://localhost:8787 跑(本地 dev 不经代理,而设备连的是同一个 Supabase,转发/广播照样通,四套件本地全绿已验证)。


Step 6 交付物(已完成:控制台页面)

用户补充需求:要一个页面能看到有哪些设备、每台是否在线,点「添加设备」复制一条命令到终端执行(配图是原版产品的 Devices 页)。

  1. src/console.ts(新增)—— 首页 / 从自描述 JSON 改为控制台:

    • 设备面板:绿点/灰点 + Online/Offline 徽标、Last seen … · v<版本>、每行 Revoke,右上角 + Add a device。首屏由服务端渲染,之后每 5s 拉 /api/devices 覆盖列表片段。

    • 接入命令卡:MCP_SERVER_URL=<当前 origin> npx @wonderwhy-er/desktop-commander@latest remote,点按钮进剪贴板;另有一个折叠项给出带代理的完整命令(本机直连 workers.dev 不通的坑,见 Step 5)。

    • Where you use it:<origin>/mcp 连接器 URL,一键复制。

    • 登录页复用 device-auth.ts 的 page();密码校验复用 constantTimeEquals。

  2. Session = 乙方案密码 + KV:POST /login 通过后往 OAUTH_KV 写 console:<随机 token>(TTL 7 天),下发 rdc_console cookie(HttpOnly + SameSite=Lax;https 时才加 Secure,本地 http://localhost 才设得进去)。

    • CSRF 双保险:SameSite=Lax 让跨站 POST 不带 cookie;两个写接口只收 JSON body,跨站 HTML 表单发不出 JSON。

  3. src/supabase.ts 加 deleteDevice() —— 删 mcp_devices 行,mcp_remote_calls.device_id 的 on delete cascade 把它的调用行一并清掉,新调用又因外键插不进去 → 这台机器立刻从 ChatGPT 的可选目标里消失。

  4. 自描述 JSON 挪到 GET /api/info(首页被控制台接管)。

  5. scripts/test-console.mjs(新增,npm run test:console)34/34 通过:登录页 → 密码错/对 → cookie 属性 → 面板渲染(含 origin 的接入命令、连接器 URL、5s 轮询、Sign out)→ /api/devices 401/200 → revoke 的非法 id / 非 JSON body / 真删(建临时行,不碰真实设备)/ 幂等 / 真实设备未被误删 → logout 后 session 失效 → /api/info。

  6. 部署并四套件对生产全绿:34/34 + 54/54 + 14/14 + 21/21 = 123 项断言(Version 8365aba8-351d-4659-b480-1dd620d7139d)。自测遗留的 console-selftest / selftest-host 孤儿行已清,DB 只剩 Mac/online。


10. 免费额度与用量预估

Supabase Free:200 并发 Realtime 连接、200 万消息/月、API 请求无限、500MB DB、5GB egress、50000 MAU、2 个活跃项目、1 周不活跃自动暂停、无备份、日志保留 1 天。 Cloudflare Free:Workers 请求量充足。

1 人 2-3 台设备的预估用量远低于 5%。

参考:原版生产门铃量 ~126k/day(代码注释)≈ 378 万/月,会超免费额度——但那是多租户商业量级,我们单用户不会触及。

风险提示:项目 1 周不活跃会被 Supabase 自动暂停(回来需手动 resume);Free 无备份。


11. 已知技术风险

  1. 端到端联调 —— ✅ 已清除(Step 5 真机通过)。

    • 设备侧协议(/mcp-info、/device/*、GoTrue session、Realtime private channel + presence)已真机验证;

    • 真实 ChatGPT 连接器已跑通,三条硬证据:

      1. ChatGPT 的「OAuth 高级设置」里列出的端点就是我们发布的 /authorize / /token / /register,授权服务器基础与资源都是本 Worker origin;

      2. 它列出的默认作用域是 mcp:tools / desktop:auth —— 这两个值只存在于我们 AS metadata 的 scopes_supported 里,不是 ChatGPT 的默认值,说明 RFC 9728 → 8414 的 discovery 真读了我们的 JSON;

      3. 注册方法自动落到 DCR(CIMD 不可用 的警告只是回退到我们唯一实现的路径;OIDC 未启用 同理,我们没发布 openid-configuration,ChatGPT 只用它取 email,grant props 里已带)。

    • 授权后:list_devices 云端直答 Mac / online / v0.2.52;start_process 真机穿越成功(metadata 里带 openai/session、openai/userAgent、openai/userLocation)。

    • 四套件全绿 34/34 + 54/54 + 14/14 + 26/26 = 128 项断言。

    • 仍未覆盖:ChatGPT 侧的手动 OAuth 端点输入(我们走自动发现即可)。

  2. 长等待 / 子请求预算 —— ✅ 真机撞过墙,已定位并修复。

    • Cloudflare Workers Free:子请求 50 次/请求(fetch() 和 KV/R2/D1 都算,limits 页)。注意 HTTP 请求本身没有时长上限(客户端连着就能一直做子请求),CPU 才是 10ms/请求。

    • 原来固定 500ms 一轮 → 理论 240s 要 480 次,实际约 40~45 轮就撞墙。真机复现:一次 read_file 因设备侧偶发变慢拖到 39s,返回 Too many subrequests by single Worker invocation。

    • 修法:自适应退避 + 轮询预算(POLL_BUDGET = 34,src/calls.ts)——头 3s 按 500ms、3~12s 按 1s、之后 5s 一次。真机每轮约 0.8s(500ms 睡眠 + REST 往返),34 轮覆盖约 2 分钟;撞预算时抛 PollBudgetExhausted,给 ChatGPT 返回人话而不是 Cloudflare 的原始错误。

    • 回归用例:test-remote-call 第 8 节 —— start_process 卡 34.6s 的单次调用必须成功(老代码要 ~70 次轮询,必然失败)。

    • 剩余限制:单次调用最长等约 2 分钟,不是 240s。要更长只能升 Paid(子请求 10,000)。

  3. private broadcast 的 RLS 策略 —— ✅ 已配置且真机通过(设备成功 Channel subscribed + Presence tracked;Step 4 进一步验证了「服务端带用户 JWT 发广播 → 设备真的收到并执行」)。

  4. 13MB 级 result 经 Workers 传递。

  5. 本机连 Supabase 直连域名偶发 DNS 解析失败(db.<ref>.supabase.co),pg 直连实测可用;失败时重试即可。

  6. Bundle 体积 —— ✅ 已排除(先前记的"离 1 MiB 上限只剩 2.9%"是错的,作废)。

    • wrangler 输出的 Total Upload: 994.96 KiB 是未压缩值,gzip: 206.50 KiB 只是参考。原来拿未压缩的 993 KiB 去比一个"1 MiB 上限",那个上限不存在。

    • 旧规则是 压缩后 3 MB(Free)——我们 gzip 206 KiB 只占 6.9%,从来就不紧张。

    • 2026-09-04 起 Cloudflare 取消了压缩后 3 MB / 10 MB 的限制,只检查未压缩 bundle,全档位 64 MiB(changelog)。我们 0.99 MiB = 上限的 1.5%,还有 60 多 MiB 余量。

    • 因此不再需要为了 bundle 而裁依赖(当初考虑过"砍掉 @cloudflare/workers-oauth-provider 换体积",理由已不成立)。要看这个数就跑 npx wrangler deploy --dry-run。

  7. 设备侧写回的假阴性 —— 已在 Worker 侧兜住,但 DB 会留下「status=failed 却有 result」的行。

    • 设备端 remote-channel.ts 的 fail-fast 兜底:结果写入的 UPDATE 已提交、HTTP 响应却在半路丢了(实测 TypeError: fetch failed,设备经本地代理连 Supabase 时出现),客户端误判成写入失败,再补一发把 status 改成 failed(不清 result)。

    • 真机复现过一次(ChatGPT 那次 ping,结果其实正常返回给了 ChatGPT)。对照真正在跑的 npm 包 dist/ 确认:真执行失败写的是 ('failed', null, error),所以**「failed 且有 result」只可能是这个假阴性** → calls.ts 以 result 为准。

    • 可选的彻底修法在设备侧(写回失败先回读一次再决定),但设备端是 npm 包,不在本仓库约束内。


12. 本地开发

cp .env.example .env      # 填好各变量(.env 已被 gitignore)
npm install
npm run migrate           # 执行 supabase/migrations/*.sql
npm run migrate:status    # 只看哪些还没跑
npm run dev               # wrangler dev → http://localhost:8787(读 .dev.vars,同样不入库)

迁移执行器用 Node pg 直连 DATABASE_URL(本机无 brew/psql,故不依赖 psql)。

Step 2 / 3 / 4 / 6 自测(需先 npm run dev):

node scripts/test-device-flow.mjs   # 14 项断言,覆盖设备配对全流程
node scripts/test-oauth-flow.mjs    # 54 项断言,覆盖 ChatGPT 侧 OAuth + MCP 全流程
npm run test:call                   # 26 项断言,真机转发(需设备在线;第 8 节是 ~35s 长任务)
npm run test:console                # 34 项断言,Step 6 控制台页面 + 两个 API
npm run tools:capture               # 重抓设备端工具定义 → tools.captured.json
node scripts/probe-broadcast.mjs real-jwt   # 探针:证明门铃真的送达(real-apikey 会证明「202 ≠ 送达」)

四个套件合计 128 项断言。建议对 http://localhost:8787 跑:本地 dev 不经代理,而设备连的是同一个 Supabase,转发与广播照样通(已验证)。对线上跑则必须带 NODE_USE_ENV_PROXY=1 + 两个 proxy 变量,且本机代理在突发请求下会断连、脚本偶发 TypeError: terminated —— 那是客户端问题,重跑即可。

test-oauth-flow.mjs 走的是 ChatGPT 的真实顺序:POST /mcp 401 拿 resource_metadata → RFC 9728/8414 discovery → RFC 7591 DCR → GET/POST /authorize 密码页 → /token 换 code → initialize → tools/list(=29) → tools/call。

真机联调(设备侧):

MCP_SERVER_URL=http://localhost:8787 npx @wonderwhy-er/desktop-commander@latest remote
# 已配对过会直接复用 ~/.desktop-commander-device/device.json;否则打印 verification_uri_complete 与配对码,
# 浏览器打开并输入 AUTH_PASSWORD 即可

MCP 端点调试用 MCP Inspector 连 http://localhost:8787/mcp。

部署:推 main 即自动上线(Cloudflare Workers Builds)

仓库已通过 Cloudflare 的 Workers Builds 连到 Worker remotedesktopcloud (Worker → 设置 → 构建 → Git 存储库)。推 commit 到 main 就会构建并部署,不用建 API token、不用 GitHub Secrets。

项

值

Git 存储库

958877748/RemoteDesktopCloud,生产分支 main

构建命令

npm run typecheck ← 类型检查不过就不部署,相当于 verify 门

部署命令

npx wrangler deploy(默认,wrangler 版本取自 package.json)

根目录

/

预览构建

已关闭(只用 main;预览环境没有 KV 绑定和运行时 secrets,开着只会在推分支时白跑一个红叉)

API 令牌

Cloudflare 自动生成(Workers Scripts: Edit + Workers KV Storage: Edit + User Details: Read)

构建历史:Worker → 部署 → View build history。一次约 1~2 分钟; 免费额度 3,000 build 分钟/月(1 并发、20 分钟超时、2 vCPU / 8 GB)。 依赖按 package-lock.json 自动安装。

选 Build command 是 npm run typecheck 而不是 wrangler deploy --dry-run:typecheck 已经能挡住 唯一会让部署出错的那类改动,dry-run 只是重复打包,deploy 自己会验 bundle。

曾写过 .github/workflows/deploy.yml 走 GitHub Actions(verify job + deploy job),因 Workers Builds 不需要手动建 Cloudflare API token、且部署历史直接挂在 Worker 上,已弃用并删除。

上线时的一次性步骤(已完成,不用重做):

npx wrangler login                        # ✅ 已完成
npx wrangler kv namespace create OAUTH_KV # ✅ 已完成,id 已填进 wrangler.jsonc
npx wrangler secret put SUPABASE_URL      # ✅ 6 个 secret 全部上传
# 其余 5 个:SUPABASE_PUBLISHABLE_KEY / SUPABASE_SERVICE_ROLE_KEY
#            AUTH_PASSWORD / USER_ID / USER_EMAIL
npm run deploy                            # 本地手动发版(等同,但会绕过 typecheck 门)
# → https://remotedesktopcloud.txdygl.workers.dev

6 个运行时 secret 存在 Cloudflare 侧,wrangler deploy 不会覆盖或删除它们 —— 所以自动部署不需要在任何 CI 里传密钥。

全局敲 wrangler 会 command not found(只是本地 devDependency),一律用 npx wrangler ...。 三个自测脚本都吃第二个参数当 baseUrl,所以同一套断言可以直接打生产: node scripts/test-oauth-flow.mjs https://remotedesktopcloud.txdygl.workers.dev(要带代理,见下)。

ChatGPT 连接器配置

  1. ChatGPT → 设置 → 连接器(Connectors)→ 添加自定义连接器

  2. URL 填 https://remotedesktopcloud.txdygl.workers.dev/mcp

  3. 授权时会跳到本服务的密码页,输入 AUTH_PASSWORD 即可(乙方案,见 §8)

  4. 连上后应看到 29 个 Desktop Commander 工具;先问一句「列出我的设备」,再让它读个文件验证转发

网络备注(本机)

需走代理才能访问 github/npm:http_proxy=http://127.0.0.1:7897;~/.ssh 无密钥,clone 一律 HTTPS

Related MCP Connectors

Related MCP Servers