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.

Related MCP server: Remote MCP Server (Authless)

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.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • Connect any AI agent to 11+ social platforms: schedule, publish & track posts via hosted MCP.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/legacyarchitects1/x-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server