Skip to main content
Glama

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:

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:

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)

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

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

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.