Skip to main content
Glama
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.