All posts

Echoly

A remote MCP server where the AI can draft posts but never approve them

How we built OAuth 2.1 sign-in, a careful consent page and an approval queue on Cloudflare Workers so Claude, ChatGPT and Cursor can write social posts without being able to publish them on their own.

6 min read

Echoly schedules social posts to X, Telegram channels, Bluesky, Mastodon and Discord. We wanted people to be able to say to their AI app, "draft two posts for tomorrow about our new update", and have those drafts land in Echoly.

The Model Context Protocol (MCP) makes the plumbing simple: expose a few tools at a URL, and Claude, ChatGPT, Cursor or VS Code can call them. The hard part is around it. Who is the app, which account is it acting for, and what may it do?

Our answer to the last question shaped the whole design: an AI can add posts and cancel posts. It cannot approve them. This post explains how that's put together on Cloudflare Workers with a D1 database.

The MCP server itself is small

The server lives at /mcp and speaks Streamable HTTP with plain JSON responses. No sessions, no server-to-client stream: every POST is answered on its own, which suits Workers.

It offers five tools:

  • list_brands: brands, connected accounts, time zone, current local time, this month's usage
  • create_post: add a post or a thread (2 to 10 posts), with an optional time, facts and images
  • list_posts, get_post: read what's queued and how deliveries went
  • cancel_post: cancel a post that hasn't started sending

The missing tool is the point. There is no approve_post. The server's instructions to the model say so directly:

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.

One detail for the model's sake: problems it can fix come back as tool results, not protocol errors.

} 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;
}

A message such as "Monthly X post limit reached (N of M used, this needs K)" is something the model can read and act on.

Sign-in: OAuth 2.1 the way MCP apps expect it

MCP clients expect OAuth, and they expect to discover it on their own:

  1. An unauthenticated request to /mcp gets a 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource".
  2. That document (RFC 9728) names the authorization server.
  3. /.well-known/oauth-authorization-server (RFC 8414) lists the endpoints. It declares only the code response type, only S256 for PKCE, and none for token endpoint auth, since every client is public.

Two ways an app can identify itself

Dynamic client registration (RFC 7591). The app POSTs its name and redirect URIs to /oauth/register and gets a client_id starting with ec_, with no secret. Registrations are capped at 300 an hour across all apps, and ones that never finished signing in are deleted after a day.

Client ID Metadata Documents (CIMD). Here the app doesn't register at all. Its client_id is an https URL that serves its own metadata. When we see one, we fetch it with care:

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;

No redirects, five seconds, 20,000 characters at most. The document's client_id must equal the URL, its redirect URIs must pass the same rules as a registered app's, and localhost or bare IP hosts aren't accepted. The result is cached for a day.

Redirect URIs

Redirect rules are strict and short:

  • https anywhere;
  • http only back to this computer (localhost, 127.0.0.1, [::1]);
  • native app schemes like cursor://, but never javascript:, data:, file:, vbscript: or blob:;
  • no fragments, no usernames or passwords in the URL.

Matching is exact, with one exception from RFC 8252 §7.3: desktop apps and CLIs listen on a random port, so a loopback URI with the same host, path and query matches whatever the port. Claude Code, for example, registers http://localhost/callback.

PKCE is mandatory

/oauth/authorize refuses anything other than code_challenge_method=S256 with a 43-character challenge. At the token endpoint:

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;
}

Authorization codes last 10 minutes. The code row is deleted in the same statement that reads it (DELETE … RETURNING), so it can't be redeemed twice, even by two requests at once.

An uncomfortable fact about dynamic registration: anyone can register an app with any name. A malicious page could register itself as "Claude" and send you a normal-looking link to our real sign-in page. The name on the consent screen proves nothing.

So the page leads with what we can actually vouch for: where the code will be sent. The return address is shown large, in code font: "If you allow it, Echoly sends you back to example.com". A loopback address is labelled "(an app on this computer)".

Then it says how much we know about the app:

  • For a CIMD app, the identity is its own https host: "The app's details are published at …".
  • For a registered app, it says plainly that the app chose its name itself and can't be verified, and to allow it only if you just started connecting from that app.

The page then lists, in plain words, what the app can do (see brands and posts, add posts that wait for approval, cancel unsent posts) and can't do (approve posts, change accounts or billing, see your login). If a brand is set to automatic approval, the page names it, because for that brand "waits for approval" isn't the whole story.

The Allow form refuses cross-site POSTs, and a "Not you? Use another email" button guards against connecting the wrong workspace.

Tokens: short, rotated and hashed

On success the app gets an access token (eo_…, one hour) and a refresh token (er_…, 60 days). Refreshing rotates both: the old refresh token is deleted as it is used, again with DELETE … RETURNING.

We store only SHA-256 hashes of codes and tokens. Looking up a bearer token means hashing what the client sent and comparing hashes:

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)

A leaked database backup doesn't hand anyone working tokens. Disconnecting an app in Settings deletes its grant and every token under it.

Why the AI can't approve

The approval queue is a small state machine:

draft ─submit→ pending ─approve→ scheduled ─due→ publishing → published / failed

Every move uses a guarded update, so two clicks at once can't both win:

UPDATE posts SET status = ?, ... WHERE id = ? AND status IN ('pending', 'draft')

Neither the MCP server nor the workspace API exposes an approve action. In the web app, approving needs a signed-in browser session and passes a same-origin check. An OAuth token or API key can put posts into pending, but nothing it can call moves them to scheduled (automatic approval, below, is decided by rules, not by the caller).

We left the approve tool out on purpose. An AI app reads web pages, emails and documents, and any of them can contain text aimed at the model. If the model could approve, a convincing paragraph somewhere could get a post published under your name. Without the tool, that path doesn't exist.

Automatic approval, with guards

Approving routine posts like daily stats by hand gets old. So on paid plans a brand (or one kind of post) can use automatic approval. The decision is still never the AI's; fixed rules the person set up make it:

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;
}

The fact check is our favourite. Every number in the post must also appear in the facts sent with it: "120 new players" needs {"newPlayers": 120}. Digits in URLs and in the brand name don't count. A made-up statistic becomes a post that waits for a human.

The repeat check compares against the brand's last 7 days of posts, ignoring case and spacing. Platforms, X above all, refuse or flag repeats.

The undo window applies even when everything passes. An auto-approved post never goes out sooner than a set delay after approval, two hours by default. Before it goes out, the owner gets an email with a link to cancel it.

If any guard fails, the post stays in "Needs approval" with a note starting "Not auto-approved:" and the reasons. Checks for unsendable posts (too long, no accounts, an account to reconnect, plan limits) apply to both paths.

Posts created through the API or MCP are also rate-limited: at most 20 a minute and 100 a day per brand.

What we'd tell someone building the same thing

  • Treat the client name from dynamic registration as untrusted input. Show the redirect host instead.
  • Decide which actions must always involve a person, and don't expose them as tools, however convenient they'd be.
  • When you do automate approval, make the rules deterministic, visible on the post, and easy to undo.

Try it

Echoly has a free plan, and the MCP connector works on every plan, Free included (automatic approval is on the paid plans). The connector URL and setup steps for Claude, ChatGPT, Claude Code, Cursor and VS Code are in the docs: https://echoly.2cstudio.app.

Written by 2C Studio, a small studio.

Try it

Echoly

echoly.2cstudio.app

More from the blog