X MCP Server
README.md
# 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 to `privacy_status: private`** — must be set explicitly to `public` or `unlisted`.
- `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 via `src/formConfigs.ts`), then:
1. 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. Uses
`claude-opus-5` (this environment's real current model ID), not
Make's internal label `claude-opus-4-8`.
2. Emails the generated report to the client via Resend
(`src/formEmail.ts`).
3. 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.
4. Logs exactly one row per submission to D1's `form_submissions` table
(`migrations/0002_form_submissions.sql`) — real status, the stage the
pipeline reached, and the exact error text on failure. Same
no-silent-failures guardrail as `post_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:
```bash
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 go
```
Until 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:
```bash
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 `agents` package** (`McpAgent`, from `agents/mcp`) — the
transport adapter. The official MCP SDK's built-in HTTP transport assumes
a long-lived Node process; Workers don't have that, so `agents` wraps 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()`) — `agents` only 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
1. developer.x.com → create/open a Project + App.
2. App → **User authentication settings** → enable OAuth 1.0a with
**Read and Write** permission (not Read-only).
3. 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.
4. 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)
```bash
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 deploy
```
### 3. 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
```bash
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 dev` and raw
JSON-RPC over curl: unauthenticated request correctly gets `401`;
`initialize` handshake returns the right protocol version and session
ID; `tools/list` correctly exposes `post_to_x`'s schema; calling it
without `confirm` returns a draft preview and writes nothing to the log;
calling it with `confirm=true` against placeholder credentials attempts
a real network call, and when that call failed (this sandbox's network
policy blocks `api.twitter.com` outbound — 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/list` correctly 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 as
`post_to_x`.
- **`upload_video`, `post_to_instagram`, `post_to_facebook`**: `tools/list`
correctly exposes all seven tools; each one's draft mode (no `confirm`)
correctly returns a preview with no API call and no log entry; each one's
`confirm=true` path, 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 the `privacy_status: private`
default is actually taking effect (not just documented) — passing no
`privacy_status` at all shows "Privacy: private" in the returned preview,
and tags are correctly threaded through.
- **`update_video_metadata`, `get_upload_status`**: calling
`update_video_metadata` with 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_status` behaves the same way with no credentials configured.
- **Type-checks clean** end to end.
- **`/forms/credit-analysis-intake`**, via `wrangler dev --local` + curl,
against the real remaining pieces, not stubs: unauthenticated `GET`
correctly renders the branded HTML form (no bearer token needed,
confirming the `/forms/*` carve-out in `src/index.ts` actually works);
`GET` for an unknown form ID correctly 404s; a `POST` missing required
fields or with a malformed email is correctly rejected (400) with
per-field errors and no D1 row written; a fully valid `POST` with no
`ANTHROPIC_API_KEY` set 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 invalid** `ANTHROPIC_API_KEY`
set, a full submission made a genuine network round-trip to
`api.anthropic.com` (this sandbox's network policy did *not* block that
host, unlike `api.twitter.com`/`api.openai.com`), got a real `401
invalid x-api-key` response back, and that exact error text landed
correctly in the `form_submissions` D1 row (verified by querying the
table directly afterward) with `status = 'failure'` and
`stage = '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
```sql
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:
```bash
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.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues