X MCP Server
X MCP Server
A self-hosted MCP (Model Context Protocol) server on Cloudflare Workers that
lets Claude post directly to X, YouTube, Instagram, and Facebook, and draft
on-brand content for all five platforms including TikTok. It also now hosts
a self-hosted intake-form system (/forms/*) that replaces Typeform for
HMP's lead-capture flows — see "Self-hosted intake forms" below. Seven MCP
tools today:
post_to_x— milestone 1, fully proven except a real credentialed test post.upload_video— needs a video already hosted somewhere (no video generation exists here). Defaults toprivacy_status: private— must be set explicitly topublicorunlisted.update_video_metadata— edit an existing video's title/description/tags. Fetches and preserves whatever fields you don't pass (YouTube's API replaces the whole metadata set per request, not a per-field patch — this tool does the fetch-and-merge for you). No draft/confirm gate; it edits something already live.get_upload_status— live processing-status check against YouTube (not a replay of the upload log), with the exact failure/rejection reason surfaced if there is one.post_to_instagram— image or video/Reels.post_to_facebook— Page feed posts, text/link or image.generate_post— drafts content for all five platforms (including TikTok) using the brand voice KB. Drafting only, never posts.
Every posting tool uses the same draft/confirm gate and D1 logging
guardrails as post_to_x. TikTok posting is not built — its API
restricts unaudited apps to private posts, which doesn't serve the actual
goal. YouTube Community Tab posts are not built — see "What's not
built, and why" below. Scheduling and a general get_post_status
across all platforms are not built. See PLATFORM_EXPANSION.md for
exactly what's needed to get each platform live.
What's not built, and why
post_community_update (YouTube Community Tab posts) was requested but
isn't built. I don't have confident knowledge that YouTube's Data API v3
has a public, documented endpoint for creating Community posts — they've
historically been a YouTube Studio-only feature, and I'm not aware of a
communityPosts.insert (or equivalent) in the standard, generally-available
API surface. Building against a guessed endpoint would compile fine and
fail every single call in production — worse than not building it, since
it would look done when it isn't. If there's a real, current, documented
endpoint for this (Google ships new API surface fairly often, and this
could have changed), point me at the docs and I'll build it properly. Also
worth knowing: any unofficial way to do this (reverse-engineered
internal APIs some third-party tools use) would be a ToS risk to the
channel and inherently unstable — not something to build against even if
it technically works today.
Brand voice content is grounded in BRAND_VOICE.md — read that before
assuming generate_post's output is final; parts of it are my best
inference from existing product content, not something dictated directly,
and it's flagged as such inline.
Self-hosted intake forms
/forms/:formId is a small, generic form-rendering and submission system —
one Worker, no external form vendor. It exists because Make.com's
Typeform-triggered email steps for HMP's Credit Analysis flow had repeated,
unconfirmed delivery issues; this gives Damon a system he controls end to
end, where "did submission X actually process" is answerable with a direct
D1 query instead of squinting at Make's execution history.
What's built (milestone 1): one form, credit-analysis-intake.
Deliberately not a general migration of every HMP form — see "First
milestone" in the spec this was built from.
GET /forms/credit-analysis-intake— serves a real HTML form, styled with HMP's obsidian/gold branding (src/formRender.ts), unauthenticated (a real visitor has no bearer token).POST /forms/credit-analysis-intake— validates the submission against the form's field definitions (configs/forms/credit-analysis-intake.json, loaded viasrc/formConfigs.ts), then:Calls the real Anthropic Messages API (
src/anthropicApi.ts) with the exact prompt text ported verbatim from the Make scenario this replaces ("20-Page Credit Analysis - Auto-Deliver + Log (Claude)", scenario ID 5761132) — confirmed via a live API call to that scenario, not from memory, so the AI-writing quality doesn't regress. Usesclaude-opus-5(this environment's real current model ID), not Make's internal labelclaude-opus-4-8.Emails the generated report to the client via Resend (
src/formEmail.ts).Sends a best-effort internal notification to
FORM_ADMIN_EMAIL(mirrors the Make scenario's second Gmail step) — its failure is logged but doesn't block the client-facing result, since the thing the client actually needed (their report) already happened.Logs exactly one row per submission to D1's
form_submissionstable (migrations/0002_form_submissions.sql) — real status, the stage the pipeline reached, and the exact error text on failure. Same no-silent-failures guardrail aspost_log.
Public, but not unprotected: /forms/* is carved out of the
MCP_AUTH_TOKEN gate in src/index.ts on purpose (see the comment there),
and instead has its own per-IP fixed-window rate limiter
(src/formRateLimit.ts, 5 submissions per 10 minutes per IP, backed by the
FORM_RATE_LIMIT KV namespace) plus server-side field validation
(required/optional, type, max length) that runs before any submission
reaches the rate-limit-consuming path or gets a D1 row.
Adding a second form (e.g. the real-estate lead intake, deferred per
the "don't migrate everything at once" instruction) means: a new JSON file
under configs/forms/, registering it in FORMS in src/formConfigs.ts,
a prompt-builder + entry in GENERATORS in src/formSubmit.ts — the
render/validate/rate-limit/log plumbing is already generic.
Setup, once secrets exist:
npx wrangler kv namespace create FORM_RATE_LIMIT
# paste the returned id into wrangler.toml, replacing REPLACE_WITH_REAL_KV_NAMESPACE_ID
npm run db:migrate:remote # picks up migrations/0002_form_submissions.sql too
npx wrangler secret put ANTHROPIC_API_KEY # a real Claude API key — NOT the OPENAI_API_KEY above
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put FORM_FROM_EMAIL # must be a Resend-verified sending address
npx wrangler secret put FORM_ADMIN_EMAIL # optional; where "submission delivered" notices goUntil these are set, GET /forms/credit-analysis-intake still renders
correctly and POST still validates input — it just returns a clear
"not configured yet" response instead of attempting generation, and writes
nothing to form_submissions in that case (no attempt was actually made).
Query submissions directly any time with:
npx wrangler d1 execute x_mcp_db --remote --command "SELECT id, created_at, status, stage, client_name, error_message FROM form_submissions ORDER BY created_at DESC LIMIT 20;"Architecture
One Cloudflare Worker, three moving parts:
src/mcp-agent.ts— the actual MCP server: tool definitions using Anthropic's official@modelcontextprotocol/sdk(McpServer,server.tool()).Cloudflare's
agentspackage (McpAgent, fromagents/mcp) — the transport adapter. The official MCP SDK's built-in HTTP transport assumes a long-lived Node process; Workers don't have that, soagentswraps the official SDK in a Durable Object to hold session state across requests. You're still writing against the official SDK's API (McpServer,server.tool()) —agentsonly supplies the plumbing to make that work on Workers at all.src/xApi.ts+src/oauth1.ts— the X API v2 client, including a from-scratch OAuth 1.0a request signer (RFC 5849) implemented against Web Crypto, since there isn't a maintained OAuth1.0a signing library built for the Workers runtime.
Every post attempt (confirm=true) is logged to D1 (src/db.ts,
migrations/0001_init.sql) — timestamp, status, and the exact error
message if it failed. Nothing about a failed post is summarized or guessed;
the real API response text is what gets logged and returned.
Why OAuth 1.0a, not OAuth 2.0
X still requires OAuth 1.0a user-context signing to post via the API, even though it offers OAuth 2.0 for other flows. OAuth 1.0a also has no token refresh step for a single personal account — the four credentials below are static once issued, which keeps this simple for a one-account server.
Security: this server is protected by a bearer token
Anyone who can reach /mcp unauthenticated could post to your X account —
so every request is checked against MCP_AUTH_TOKEN before it reaches any
MCP session or tool logic (src/index.ts, using a constant-time comparison).
This is proportionate for a personal, single-user server; if this is ever
shared with other people or used from clients you don't fully trust, that's
the point to move to a real OAuth consent flow
(@cloudflare/workers-oauth-provider, which agents also supports) instead
of a shared static secret.
Setup
1. X Developer App
developer.x.com → create/open a Project + App.
App → User authentication settings → enable OAuth 1.0a with Read and Write permission (not Read-only).
Generate API Key, API Key Secret, Access Token, Access Token Secret — regenerate the Access Token after setting Read+Write, or it'll carry stale read-only scope.
Confirm your API access tier actually allows posting. X's tiers have changed more than once; check your Developer Portal's current plan before assuming the free tier covers this.
2. Cloudflare resources (needs your account — I can't do this part)
npm install
npx wrangler login
# D1 database:
npx wrangler d1 create x_mcp_db
# paste the returned database_id into wrangler.toml, replacing
# REPLACE_WITH_REAL_D1_DATABASE_ID, then:
npm run db:migrate:remote
# KV namespace (backs the /forms/* rate limiter — see "Self-hosted intake
# forms" above):
npx wrangler kv namespace create FORM_RATE_LIMIT
# paste the returned id into wrangler.toml, replacing
# REPLACE_WITH_REAL_KV_NAMESPACE_ID
# Secrets (you'll be prompted to paste each value):
npx wrangler secret put X_API_KEY
npx wrangler secret put X_API_SECRET
npx wrangler secret put X_ACCESS_TOKEN
npx wrangler secret put X_ACCESS_TOKEN_SECRET
npx wrangler secret put MCP_AUTH_TOKEN # generate with: openssl rand -base64 32
npx wrangler secret put OPENAI_API_KEY # used by generate_post; can be a fresh key
npx wrangler deploy3. Point Claude at it
For Claude clients that support remote MCP servers, add this server using
your deployed Worker URL (https://x-mcp-server.YOUR-SUBDOMAIN.workers.dev/mcp)
with an Authorization: Bearer <MCP_AUTH_TOKEN> header. Exact config format
depends on which Claude client you're using (Claude Code, Claude Desktop,
claude.ai connectors) — let me know which one and I'll give you the precise
config snippet.
Local development
npm install
npx wrangler d1 migrations apply x_mcp_db --local
npx wrangler dev --local.dev.vars (gitignored, never commit):
X_API_KEY=...
X_API_SECRET=...
X_ACCESS_TOKEN=...
X_ACCESS_TOKEN_SECRET=...
MCP_AUTH_TOKEN=some-local-test-secret
OPENAI_API_KEY=...
# Optional — omit any of these and that platform's tool returns a clear
# "not configured yet" message instead of failing confusingly:
YT_CLIENT_ID=...
YT_CLIENT_SECRET=...
YT_REFRESH_TOKEN=...
META_PAGE_ACCESS_TOKEN=...
META_PAGE_ID=...
META_IG_USER_ID=...
# Optional — self-hosted intake forms (/forms/*). Omit and the form still
# renders and validates; it just returns "not configured yet" on submit:
ANTHROPIC_API_KEY=...
RESEND_API_KEY=...
FORM_FROM_EMAIL=...
FORM_ADMIN_EMAIL=...Note: wrangler dev --local simulates KV/D1 with a real id placeholder
fine, but the FORM_RATE_LIMIT KV namespace binding still needs to exist
in wrangler.toml (the placeholder id is enough locally).
What's actually verified, and how
I don't have real X API credentials, so I couldn't fire a real post from here — but everything short of the live API call is verified directly, not assumed:
OAuth1.0a signing (
src/oauth1.ts): 16 property-based checks run against the real implementation — correct RFC3986 percent-encoding of the exact characters OAuth1.0a cares about (spaces,!'()*, reserved punctuation), deterministic output for identical inputs, the signature changing when any single input changes (consumer key/secret, token/token secret, nonce, timestamp, any parameter, the URL, the HTTP method), correctly sorted parameters regardless of insertion order, and correct HMAC-SHA1 output length. I deliberately didn't test against a from-memory external test vector (X's classic documented example) — a misremembered 300-character string would be more likely to send me chasing a false failure than catch a real bug.The whole MCP + logging pipeline, for real, via
wrangler devand raw JSON-RPC over curl: unauthenticated request correctly gets401;initializehandshake returns the right protocol version and session ID;tools/listcorrectly exposespost_to_x's schema; calling it withoutconfirmreturns a draft preview and writes nothing to the log; calling it withconfirm=trueagainst placeholder credentials attempts a real network call, and when that call failed (this sandbox's network policy blocksapi.twitter.comoutbound — a good stand-in for "the API call failed" here), the exact error text was returned to the caller and landed correctly in the D1 log with a timestamp, verified by querying the table directly afterward.generate_post, the same way:tools/listcorrectly exposes both tools; an invalid platform value is correctly rejected by schema validation with a clear error (never silently coerced); calling it with a placeholder OpenAI key attempts a real network call and surfaces the exact failure text back to the caller (isError: true), same pattern aspost_to_x.upload_video,post_to_instagram,post_to_facebook:tools/listcorrectly exposes all seven tools; each one's draft mode (noconfirm) correctly returns a preview with no API call and no log entry; each one'sconfirm=truepath, with no platform credentials set at all, correctly returns its specific "not configured yet — set X, Y, Z" message rather than crashing or attempting a call with undefined credentials.upload_video's draft preview confirmed theprivacy_status: privatedefault is actually taking effect (not just documented) — passing noprivacy_statusat all shows "Privacy: private" in the returned preview, and tags are correctly threaded through.update_video_metadata,get_upload_status: callingupdate_video_metadatawith no fields at all is correctly rejected before any credential check or API call ("Nothing to update"); with a real field but no YouTube credentials configured, it returns the same "not configured yet" message as the others, not a crash from the fetch-then-merge logic running against undefined credentials.get_upload_statusbehaves the same way with no credentials configured.Type-checks clean end to end.
/forms/credit-analysis-intake, viawrangler dev --local+ curl, against the real remaining pieces, not stubs: unauthenticatedGETcorrectly renders the branded HTML form (no bearer token needed, confirming the/forms/*carve-out insrc/index.tsactually works);GETfor an unknown form ID correctly 404s; aPOSTmissing required fields or with a malformed email is correctly rejected (400) with per-field errors and no D1 row written; a fully validPOSTwith noANTHROPIC_API_KEYset correctly returns "not configured yet" (503) and still writes no D1 row (no attempt was actually made, so none is logged); the per-IP rate limiter correctly starts returning 429 after 5 submission attempts in the same window (counting failed/rejected attempts too, which is intentional — validation-failure spam is still spam); and with a real but intentionally invalidANTHROPIC_API_KEYset, a full submission made a genuine network round-trip toapi.anthropic.com(this sandbox's network policy did not block that host, unlikeapi.twitter.com/api.openai.com), got a real401 invalid x-api-keyresponse back, and that exact error text landed correctly in theform_submissionsD1 row (verified by querying the table directly afterward) withstatus = 'failure'andstage = 'generation'. The client-email and admin-email stages (src/formEmail.ts) are structurally identical to the already-proven Resend pattern from the home-care-intake-bot project, but weren't exercised against a real Resend key here, since generation has to succeed first to reach them.
What's not verified: an actual successful post against any of the social
platform APIs (X, YouTube, Instagram, Facebook), an actual successful
OpenAI generation for generate_post, or an actual successful
/forms/credit-analysis-intake submission all the way through to a
delivered email — all of that needs real credentials Damon holds. That's
the real milestone-1 test for both halves of this server: once secrets are
set and this is deployed, one real post through Claude and one real form
submission that lands in an inbox is what proves each end-to-end.
Database schema
CREATE TABLE post_log (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
platform TEXT NOT NULL,
post_text TEXT NOT NULL,
media_url TEXT,
status TEXT NOT NULL, -- 'success' | 'failure'
external_post_id TEXT, -- the X post ID, on success
error_message TEXT, -- the exact error, on failure
raw_response TEXT -- full API response body
);
CREATE TABLE form_submissions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
form_id TEXT NOT NULL,
client_name TEXT NOT NULL,
client_email TEXT NOT NULL,
fields_json TEXT NOT NULL, -- full validated submission, as JSON
status TEXT NOT NULL, -- 'success' | 'failure'
stage TEXT NOT NULL, -- 'generation' | 'client_email' | 'admin_email' | 'complete'
generated_report TEXT, -- the AI-written report, if generation succeeded
error_message TEXT, -- the exact error, if status = 'failure'
raw_anthropic_response TEXT,
raw_email_response TEXT
);Query them directly any time with:
npx wrangler d1 execute x_mcp_db --remote --command "SELECT * FROM post_log ORDER BY created_at DESC LIMIT 20;"
npx wrangler d1 execute x_mcp_db --remote --command "SELECT id, created_at, status, stage, client_name, error_message FROM form_submissions ORDER BY created_at DESC LIMIT 20;"(That's effectively get_post_status/list_scheduled_posts by hand, until
those tools are built for real.)
Known limitation: media upload
post_to_x accepts an optional media_url, uploaded via X's v1.1
simple (non-chunked) media endpoint — images under 5MB only. Video/GIF/
larger images need the chunked INIT/APPEND/FINALIZE flow, which isn't
implemented. This is also the less-exercised code path for milestone 1 (the
spec's one real test post is about proving text posting) — treat it as
needing its own dedicated test before relying on it for anything real.