Echoly 帮你把社交帖子排程发到 X、Telegram 频道、Bluesky、Mastodon 和 Discord。我们希望大家能对自己的 AI 应用说一句"帮我写两篇明天发的帖子,讲我们的新更新",草稿就直接出现在 Echoly 里。
Model Context Protocol(MCP)让接线这件事变得很简单:在一个网址上提供几个工具,Claude、ChatGPT、Cursor 或 VS Code 就能调用它们。难的是周边的问题:这个应用是谁?它代表哪个账号?它能做什么?
我们对最后一个问题的回答决定了整个设计:AI 可以新增帖子、取消帖子,但不能批准帖子。这篇文章讲我们怎么在 Cloudflare Workers 和 D1 数据库上把这件事做出来。
MCP 服务器本身很小
服务器在 /mcp,使用 Streamable HTTP,返回普通的 JSON。没有会话,也没有服务器推给客户端的数据流:每个 POST 各自独立回应,这很适合 Workers。
它提供五个工具:
list_brands:品牌、已连接的账号、时区、当前当地时间、本月用量create_post:新增一篇帖子或一串帖子(2 到 10 篇),可以附上时间、事实和图片list_posts、get_post:查看排队中的帖子和发送情况cancel_post:取消还没开始发送的帖子
少掉的那个工具才是重点。没有 approve_post。服务器给模型的说明里直接写明了:
create_post adds a post. It waits for the person's approval in Echoly unless the brand uses automatic approval; you cannot approve posts, so share the returned url for review.
(意思是:create_post 会新增一篇帖子,它会在 Echoly 里等用户批准,除非这个品牌开了自动批准;你不能批准帖子,所以请把返回的网址分享给用户审核。)
还有一个为模型着想的细节:模型能自己修正的问题,会作为工具结果返回,而不是协议错误。
} catch (err) {
// Problems the AI can fix (bad brand, over a limit…) are tool results, so it sees them and can adjust.
if (err instanceof ApiError) return { content: [{ type: "text", text: err.message }], isError: true };
throw err;
}
像"Monthly X post limit reached (N of M used, this needs K)"(本月 X 帖子额度已用完:已用 N/M,这次需要 K)这样的消息,模型读得懂,也能据此调整。
登录:照 MCP 应用期待的方式做 OAuth 2.1
MCP 客户端期待用 OAuth,而且期待自己发现它:
- 未认证的请求打到
/mcp,会得到401,附带WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"。 - 那份文档(RFC 9728)指出授权服务器是谁。
/.well-known/oauth-authorization-server(RFC 8414)列出各个端点。它只声明code这一种 response type、PKCE 只接受S256,token 端点的认证方式是none,因为每个客户端都是公开客户端。
应用表明身份的两种方式
动态客户端注册(RFC 7591)。应用把名称和回传网址 POST 到 /oauth/register,拿到一个以 ec_ 开头的 client_id,没有密钥。所有应用加起来每小时最多注册 300 个,注册后一直没完成登录的会在一天后删除。
客户端 ID 元数据文档(CIMD)。这种方式下应用完全不用注册。它的 client_id 本身就是一个 https 网址,网址上放着它自己的元数据。看到这种 ID 时,我们会很小心地去抓取:
const res = await fetch(id, { headers: { accept: "application/json" }, redirect: "manual", signal: AbortSignal.timeout(5000) });
const text = res.ok ? await res.text() : "";
if (!text || text.length > 20_000) return null;
不跟随重定向,最多等五秒,最多 20,000 个字符。文档里的 client_id 必须等于这个网址,回传网址必须通过跟注册应用一样的规则,而且不接受 localhost 或纯 IP 主机。结果会缓存一天。
回传网址
回传网址的规则严格又简短:
https哪里都可以;http只能回到这台电脑(localhost、127.0.0.1、[::1]);- 可以用
cursor://这类原生应用协议,但永远不接受javascript:、data:、file:、vbscript:或blob:; - 不能带片段(#),网址里也不能带用户名或密码。
比对是完全相符,只有一个例外,来自 RFC 8252 §7.3:桌面应用和命令行工具会在随机端口上监听,所以本机回传网址只要主机、路径和查询相同,端口不同也算相符。比如 Claude Code 注册的是 http://localhost/callback。
PKCE 是必须的
/oauth/authorize 只接受 code_challenge_method=S256,challenge 必须是 43 个字符,其他一律拒绝。在 token 端点:
export async function pkceMatches(verifier: string, challenge: string): Promise<boolean> {
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false;
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", utf8.encode(verifier)));
return b64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") === challenge;
}
授权码有效期 10 分钟。读取授权码的同一条语句就会把它删掉(DELETE … RETURNING),所以它没法被兑换两次,就算两个请求同时来也一样。
授权页
动态注册有个让人不太舒服的事实:任何人都能用任何名字注册一个应用。一个恶意网页可以把自己注册成"Claude",然后发给你一个看起来很正常的链接,指向我们真正的登录页。授权页上显示的名字什么都证明不了。
所以这个页面最先展示的是我们真正能担保的东西:授权码会被送到哪里。回传地址用大号的等宽字体显示:"If you allow it, Echoly sends you back to example.com"(如果你允许,Echoly 会把你送回 example.com)。本机地址会标注"(an app on this computer)"(这台电脑上的应用)。
接着,页面说明我们对这个应用了解多少:
- CIMD 应用的身份就是它自己的 https 主机:"The app's details are published at …"(这个应用的资料发布在……)。
- 注册来的应用,页面会直说:名字是应用自己取的,无法验证;只有在你刚刚从那个应用开始连接时,才应该允许。
然后页面用白话列出这个应用能做什么(查看品牌和帖子、新增要等批准的帖子、取消还没发送的帖子),以及不能做什么(批准帖子、更改账号或付费设置、看到你的登录信息)。如果某个品牌开了自动批准,页面会点名这个品牌,因为对它来说,"要等批准"并不是全部真相。
"允许"表单拒绝跨站 POST,还有一个"Not you? Use another email"(不是你?换个邮箱)按钮,防止连错工作区。
Token:短效、轮换、只存哈希
成功之后,应用会拿到一个 access token(eo_…,一小时)和一个 refresh token(er_…,60 天)。刷新时两个都会轮换:旧的 refresh token 在使用的同时就被删除,同样用 DELETE … RETURNING。
授权码和 token 我们都只存 SHA-256 哈希。查一个 bearer token,就是把客户端送来的值算哈希,再比对哈希:
const m = /^Bearer\s+(eo_[0-9a-f]{64})\s*$/i.exec(request.headers.get("authorization") ?? "");
// ... WHERE t.token_hash = ? AND t.kind = 'access' AND t.expires_at > ?
.bind(await sha256Hex(m[1]), now)
就算数据库备份外泄,也不会让任何人拿到能用的 token。在设置里断开一个应用,会删除它的授权和底下所有的 token。
为什么 AI 不能批准
审核队列是一个小小的状态机:
draft ─submit→ pending ─approve→ scheduled ─due→ publishing → published / failed
每一次状态转换都用带条件的更新,所以同时点两下,不可能两下都成功:
UPDATE posts SET status = ?, ... WHERE id = ? AND status IN ('pending', 'draft')
MCP 服务器和工作区 API 都没有提供批准的操作。在网页后台,批准需要已登录的浏览器会话,并且要通过同源检查。一个 OAuth token 或 API key 可以把帖子放进 pending,但它能调用的任何东西都没法把帖子移到 scheduled(下面讲的自动批准,是由规则决定的,不是由调用者决定的)。
我们是故意不做批准工具的。AI 应用会读网页、邮件和文件,其中任何一份都可能藏着写给模型看的文字。如果模型能批准,某处一段够有说服力的文字,就可能让一篇帖子以你的名义发出去。没有这个工具,这条路就根本不存在。
自动批准,加上防线
每天的统计数据这类例行帖子,一篇篇手动批准很快就烦了。所以在付费方案上,一个品牌(或某一类帖子)可以使用自动批准。决定权仍然永远不在 AI 手上,而是由用户事先设好的固定规则来决定:
export function autoGuards(c: AutoContext): string[] {
const out: string[] = [];
if (!c.planAllowsAuto) out.push("Automatic approval is not in your plan");
if (c.paused) out.push("The brand is paused");
const unchecked = uncheckedNumbers(c.text, c.facts, [c.brandName]);
if (unchecked.length) out.push(`Fact check: ${unchecked.join(", ")} not found in facts`);
const hits = blockedHits(c.text, c.blockedWords);
if (hits.length) out.push(`Blocked words: ${hits.join(", ")}`);
if (c.sameDay >= c.dailyCap) out.push(`Already ${c.sameDay} posts that day (daily cap ${c.dailyCap})`);
if (hasLink(c.text) && c.sameDayLinks >= c.dailyLinkCap) out.push(`Already ${c.sameDayLinks} posts with links that day (cap ${c.dailyLinkCap})`);
if (c.duplicateOf) out.push(`Same text as a post from ${new Date(c.duplicateOf).toISOString().slice(0, 10)}`);
return out;
}
我们最喜欢的是事实检查。帖子里的每一个数字,都必须出现在随帖子一起送来的 facts 里:"120 new players"(新增 120 位玩家)就需要 {"newPlayers": 120}。网址里的数字和品牌名里的数字不算。一个编出来的统计数字,结果就是一篇等人来审的帖子。
重复检查会跟这个品牌过去 7 天的帖子比对,忽略大小写和空白。各个平台,尤其是 X,会拒绝或标记重复内容。
反悔时间就算所有检查都通过也照样生效。自动批准的帖子,最早也要在批准后一段时间才会发出,默认是两小时。发出之前,账号主人会收到一封邮件,里面有取消的链接。
只要有任何一道防线没过,帖子就会留在"Needs approval"(待批准),并附上一条以"Not auto-approved:"(未自动批准:)开头的说明和原因。至于发不出去的帖子(太长、没有账号、有账号要重新连接、超出方案额度),两条路径都会检查。
通过 API 或 MCP 建立的帖子也有频率限制:每个品牌每分钟最多 20 篇,每天最多 100 篇。
给做同样东西的人的建议
- 把动态注册来的客户端名称当成不可信的输入。改为显示回传网址的主机。
- 想清楚哪些操作一定要有人参与,然后别把它们做成工具,不管那样有多方便。
- 真的要自动批准时,让规则是确定的、在帖子上看得到、而且容易撤销。
试试看
Echoly 有免费方案,MCP 连接器在每个方案都能用,免费方案也可以(自动批准在付费方案)。连接器网址,以及 Claude、ChatGPT、Claude Code、Cursor 和 VS Code 的设置步骤,都在文档里:https://echoly.2cstudio.app。
作者:2C Studio,一间小工作室。