An MCP server that gives an LLM the X (Twitter) API: read and search posts, manage users and lists, upload media, and publish — as the account whose keys it runs on.
Website · Costs · Credentials · Tools · Quick start · Configuration · Architecture · Contributing
--- ## What it is `mcp-x` is a [Model Context Protocol](https://modelcontextprotocol.io) server written in Go. It exposes the **X API v2** to any MCP-compatible client (Claude Desktop, IDE agents, custom LLM apps) as 42 tools covering posts, users, lists and media. It authenticates with **OAuth 1.0a user context**, which means every call acts *as a real X account* — the one the four keys belong to. `x_post_create` publishes publicly. `x_user_follow` really follows. `x_post_delete` is irreversible. This is not a sandbox, and it is not free: see [The X API costs money](#the-x-api-costs-money) before you wire it into an agent. Both transports the MCP SDK supports are available and expose the identical tool set: - **stdio** — the client launches the binary and talks over stdin/stdout (the default, ideal for desktop clients). - **http** — a long-running streamable HTTP server (useful for remote/shared deployments). --- ## The X API costs money > [!WARNING] > **There is no free tier any more.** X retired the Free/Basic/Pro subscription tiers for new developers and moved to **pay-per-use credits**: you buy credits upfront in the [Developer Console](https://developer.x.com) and every request deducts from the balance in real time. Legacy Basic ($200/mo) and Pro ($5,000/mo) subscriptions survive only for accounts that already had them; Enterprise starts around $42,000/mo. A new developer account today gets pay-per-use and nothing else. Rates at the time of writing ([official pricing](https://docs.x.com/x-api/getting-started/pricing) — always re-check the Console, they have changed several times in 2026): | Operation | Price | | :--- | :--- | | Post read | **$0.005** per post returned | | Owned read (your own posts, bookmarks, followers, likes, lists) | **$0.001** per resource | | User read | **$0.010** per user returned | | Likes / mutes / blocks read | **$0.001** per resource | | Followers / following read | **$0.010** per resource | | **Publishing a post** | **$0.015** per request | | **Publishing a post containing a URL** | **$0.200** per request | | Like / repost and other interactions | **$0.015** per request | | List and bookmark writes | $0.005–$0.010 per request | Two things follow from this, and both are baked into the server: - **Reads are charged per *resource returned*, not per request.** `max_results: 100` on `x_posts_search` costs twenty times what `max_results: 5` costs for the same query. Every read tool's description tells the model to ask for the smallest `max_results` that answers the question, and the batching tools (`x_posts_lookup`, `x_users_lookup`) tell it to batch rather than loop. - **`x_posts_count` does not consume the post-read budget.** It returns match counts bucketed by minute/hour/day for the same query syntax. Size a topic with `x_posts_count` first, then pay for `x_posts_search`. X also **deduplicates**: the same resource fetched twice inside a 24-hour UTC window is billed once. And pay-per-use is capped at 3 million post reads per billing cycle — past that, only Enterprise. When the money runs out the API answers with a distinct error, and the server maps it to a message that explicitly tells the model **not to retry** — see [Errors](#errors). --- ## Credentials The server needs **four** OAuth 1.0a values, all from one X app: ``` X_API_KEY X_API_KEY_SECRET X_ACCESS_TOKEN X_ACCESS_TOKEN_SECRET ``` The first pair identifies the *app*; the second pair identifies the *account acting through it*. `mcp-x` uses `AuthenMethodOAuth1UserContext`, not app-only bearer auth, because every write endpoint and every "me" endpoint (`x_users_me`, `x_posts_home`, `x_bookmarks_list`, mentions) requires a user context. There is no bearer-token mode. ### Getting them — and the one step everybody gets wrong 1. Go to [developer.x.com](https://developer.x.com) → your project → your app. 2. Open **User authentication settings** and set **App permissions** to **Read and Write**. Do this **first**. 3. *Only then* go to **Keys and tokens** and generate the **Access Token and Secret**. > [!IMPORTANT] > **An access token permanently keeps the permissions the app had at the moment it was generated.** If you created the token while the app was Read-only and then flipped the app to Read and Write, the token is *still* read-only. Nothing about the app settings page will tell you this. Every write will fail with X's `oauth1-permissions` problem type, forever, until you go back to **Keys and tokens** and **regenerate** the Access Token and Secret. > > This is the single most common setup failure with the X API, which is why the server checks for it at startup and refuses to start with: > > ``` > the access token is read-only: set the app permissions to Read and Write > in the X Developer Console, then regenerate the Access Token and Secret > ``` > > Regenerating the *API Key/Secret* is not the fix. Regenerate the **Access Token and Secret**. ### Startup verification Before registering a single tool, the server calls `GET /2/users/me` once (`client.Bootstrap`) with a 15-second deadline. This does three jobs: - proves the four keys are valid — bad keys fail the process, not the first tool call; - catches the read-only-token trap above; - caches the key owner's numeric user id, because every write endpoint is `POST /2/users/:id/...` and looking the id up per write would be another billed request. A failure here is fatal by design. A server that starts and then fails every call is worse than one that does not start. The cost of that choice is worth stating plainly: **there is no way to try this server without a funded X developer account.** No credentials means no startup, which means no tool list — `/mcp` and `claude mcp list` will show a failed connection and nothing else. To confirm an install short of that, run the binary with `-version`, and read the [Tools](#tools) section for what it would have exposed. ### Keys are secrets The four values are credentials for a live account with write access. Keep them in a file only you can read, pass it with `-env`, and never commit it — `.env` is gitignored, `.env.example` is the template. When running under an MCP client, the client's `env` block works too; it takes precedence over the `.env` file. --- ## Tools 42 tools in four groups. Every tool carries MCP annotations: `readOnlyHint` on reads, `destructiveHint` on anything irreversible (`x_post_delete`, `x_list_delete`, unlike, unrepost, unfollow, member removal). Each returns a structured JSON payload matching its output schema; the SDK mirrors the same JSON into the text content block for clients that do not read `structuredContent`. Every tool accepts an optional `timeout_ms`, clamped into the group's `[MIN, MAX]` window (see [Limits](#limits)). ### Posts — reading | Tool | Description | | :--- | :--- | | **`x_posts_search`** | Searches recent posts for several queries **in parallel**. Recent search reaches back **7 days only**. | | **`x_posts_count`** | Counts matches per query bucketed by minute/hour/day. **Does not spend post reads** — use it to size a topic before searching. | | **`x_posts_lookup`** | Fetches up to **100 posts by id** in one call. | | **`x_posts_by_user`** | Recent posts for several usernames, fetched in parallel. | | **`x_posts_mentions`** | Posts mentioning the key owner. | | **`x_posts_home`** | The key owner's home timeline. | | **`x_posts_quotes`** | Posts quoting a given post. | | **`x_posts_liked`** | Posts the key owner liked. | | **`x_bookmarks_list`** | The key owner's bookmarks. | | **`x_post_liked_by`** | Users who liked a given post. | | **`x_post_reposted_by`** | Users who reposted a given post. | #### `x_posts_search` | Parameter | Type | Default | Notes | | :--- | :--- | :--- | :--- | | `queries` | `[]string` | — | **Required.** Run in parallel, capped at `POSTS_MAX_QUERIES` (`5`). ≤ 512 chars each. | | `max_results` | `int` | `10` | Posts **per query**, 1..100. Every one is billed. | | `sort_order` | `string` | — | `recency` (newest first) or `relevancy` (best match). | | `days` | `int` | `7` | How far back, 1..7. The API cannot go further. | | `include_retweets` | `bool` | `false` | When false the server appends `-is:retweet` to every query. | | `timeout_ms` | `int64` | `15000` | Whole-call timeout, clamped to `[2000, 60000]`. | Query operators go **inside the query string**: | Operator | Meaning | | :--- | :--- | | *space* | AND | | `OR` | explicit OR | | `-term` | NOT | | `( )` | grouping | | `from:user` / `to:user` | by author / by recipient | | `@user` / `#tag` | mentions / hashtags | | `"exact phrase"` | exact phrase | | `lang:en` | language | | `is:retweet` `is:reply` `is:quote` `is:verified` | post kind | | `has:media` `has:images` `has:videos` `has:links` | attachments | | `url:example.com` | linked domain | | `conversation_id:123` | one thread | #### `x_posts_count` | Parameter | Type | Default | Notes | | :--- | :--- | :--- | :--- | | `queries` | `[]string` | — | **Required.** Same operators as `x_posts_search`. | | `granularity` | `string` | `hour` | `minute`, `hour` or `day`. | | `days` | `int` | `7` | 1..7, same window. | | `timeout_ms` | `int64` | `15000` | Clamped to `[2000, 60000]`. | #### `x_posts_lookup` | Parameter | Type | Default | Notes | | :--- | :--- | :--- | :--- | | `ids` | `[]string` | — | **Required.** 1..100 post ids. Batch them; do not call once per id. | #### `x_posts_by_user` | Parameter | Type | Default | Notes | | :--- | :--- | :--- | :--- | | `usernames` | `[]string` | — | **Required.** Without the `@`. Capped at `POSTS_MAX_USERNAMES` (`5`), fetched in parallel. | | `max_results` | `int` | `10` | Posts per user, 1..100. | | `exclude` | `[]string` | — | `replies` and/or `retweets`. | | `days` | `int` | `7` | 1..7. | | `timeout_ms` | `int64` | `15000` | Clamped to `[2000, 60000]`. | #### Timeline tools `x_posts_mentions`, `x_posts_home`, `x_posts_liked` and `x_bookmarks_list` share one shape: `max_results` (1..100), `pagination_token`, `timeout_ms`. `x_posts_quotes`, `x_post_liked_by` and `x_post_reposted_by` add a required `id`. Pagination is explicit and manual: a response carries `next_token`, and you pass it back as `pagination_token` for the next page. The server never walks pages on its own — each page is billed, so that decision stays with the caller. ### Posts — writing | Tool | Annotation | Description | | :--- | :--- | :--- | | **`x_post_create`** | write | Publishes a post. **Public and billed** ($0.015, or $0.20 with a link). | | **`x_post_delete`** | destructive | Deletes one of your posts. Irreversible. | | **`x_post_like`** / **`x_post_unlike`** | write / destructive | Like is public. | | **`x_post_repost`** / **`x_post_unrepost`** | write / destructive | Repost is public. | #### `x_post_create` | Parameter | Type | Notes | | :--- | :--- | :--- | | `text` | `string` | ≤ 280 characters. Required unless `media_ids` is set. | | `reply_to_id` | `string` | Reply to this post. | | `quote_id` | `string` | Quote this post. | | `media_ids` | `[]string` | 1..4 ids from `x_media_upload`. | | `poll` | `object` | `options` (2..4) + `duration_minutes` (5..10080). **Cannot be combined with `media_ids`.** | | `reply_settings` | `string` | `following`, `mentionedUsers`, `subscribers` or `verified`. | Publishing the same text twice in a row is rejected by X as a duplicate — the server surfaces that as a distinct message rather than a generic failure. ### Users | Tool | Annotation | Description | | :--- | :--- | :--- | | **`x_users_lookup`** | read | Up to **100 usernames or 100 ids** in one call — one or the other, not both. Unresolved names come back in `not_found`. | | **`x_users_me`** | read | The profile behind the keys. | | **`x_user_followers`** / **`x_user_following`** | read | One page at a time; `max_results` up to 1000. | | **`x_user_follow`** / **`x_user_unfollow`** | write / destructive | Public. | | **`x_user_mute`** / **`x_user_unmute`** | write | A mute is silent: the other account is not told and can still see you. | | **`x_users_muted`** / **`x_users_blocked`** | read | The key owner's muted / blocked accounts. | Blocking and unblocking are deliberately **not** exposed. Reading the block list is. ### Lists | Tool | Annotation | Description | | :--- | :--- | :--- | | **`x_list_create`** | write | Name ≤ 25 chars, description ≤ 100. Private lists are owner-only. | | **`x_list_update`** | write | Send only the fields you want changed. | | **`x_list_delete`** | destructive | Irreversible: list, membership and followers all go. | | **`x_list_get`** | read | One list with member and follower counts. | | **`x_lists_owned`** | read | Lists owned by a username, or by the key owner when omitted. | | **`x_list_posts`** | read | Recent posts from the list's members. Billed per post. | | **`x_list_members`** | read | Accounts in the list, paginated. | | **`x_list_member_add`** / **`x_list_member_remove`** | write / destructive | By username. | | **`x_list_follow`** / **`x_list_unfollow`** | write / destructive | Public lists only. | | **`x_list_pin`** / **`x_list_unpin`** / **`x_lists_pinned`** | write / write / read | Pinned lists of the key owner. | ### Media #### `x_media_upload` Uploads an image, GIF or video with the chunked INIT → APPEND → FINALIZE flow and returns a `media_id` for `x_post_create`. | Parameter | Type | Notes | | :--- | :--- | :--- | | `path` | `string` | Local file. **Must sit inside `MEDIA_ROOT`.** | | `base64` | `string` | File contents instead of a path; requires `mime`. | | `mime` | `string` | e.g. `image/jpeg`, `video/mp4`. Required with `base64`. | | `category` | `string` | **Required.** `tweet_image` (≤ 5 MB), `tweet_gif` (≤ 15 MB), `tweet_video` (≤ 512 MB). | | `timeout_ms` | `int64` | Default `120000`, clamped to `[5000, 600000]` — covers upload *and* X's transcoding. | Two things to know: - **`MEDIA_ROOT` is a hard boundary.** It is required configuration with no default, and the server refuses any path resolving outside it, symlinks included. Without it an LLM with this tool could read any file on the host and post it. Choose a dedicated directory. - **Video is transcoded asynchronously by X.** The server polls FINALIZE status every `MEDIA_POLL_INTERVAL_MS`, but a slow encode can still return `status: "pending"`. The `media_id` is valid but not yet postable — retry `x_post_create` shortly. A `media_id` expires after a while and is meant for a single post. ### Partial results The fan-out tools — `x_posts_search`, `x_posts_count`, `x_posts_by_user` — run their inputs in parallel and return **one entry per query/username, each with its own `status`**, so one bad input does not lose the results that worked. `x_posts_lookup` does the same per id (`not found` sits next to the posts that resolved), and `x_users_lookup` collects unresolved names in `not_found`. A call fails outright only when the input is rejected before any work starts, or when **every** item in it fails. ### Errors Failures come back as a tool result with `isError: true` and a plain-text message, not as a JSON-RPC error — the model reads the message and can correct the call itself. The messages are written for a model rather than a log reader, so the ones that must not be retried say so explicitly. | Message | Meaning | | :--- | :--- | | `the access token is read-only: …regenerate the Access Token and Secret` | The [read-only-token trap](#getting-them--and-the-one-step-everybody-gets-wrong). | | `the monthly usage cap for this project is reached; …do not retry` | Usage cap hit. Nothing works until it resets. | | `the project has no X API credits left; …do not retry` | Credits exhausted. They do not reset — top up in the Console. | | `rate limit exceeded; the window resets in about 15 minutes` | Back off; do not hammer. | | `X rejected the credentials; check the four keys` | Bad or revoked keys. | | `not authorized for this resource` | Protected, deleted, or somebody else's. | | `X rejected this as a duplicate of a recent post` | Change the text. | | `user not found` / `post not found` / `list not found` | Self-explanatory. | | `X failed to process the uploaded media; re-encode the file` | Transcoding failed. | | `media exceeds the size limit for its category` | 5 MB / 15 MB / 512 MB. | | `the path is outside the directory this server is allowed to read` | `MEDIA_ROOT` violation. | | `post text exceeds 280 characters` | — | | `a post needs text unless it carries media` | — | | `a poll needs 2 to 4 options and a duration between 5 and 10080 minutes` | — | | `a post carries at most 4 media items` | — | | `too many ids in one call; the limit is 100` | — | | `invalid username: 1 to 15 letters, digits or underscores` | Validated locally, before spending a request. | | `every query failed; the X API may be unreachable` | All items failed. | | `the call timed out; retry with a larger timeout_ms or fewer inputs` | — | | `the X API is unavailable` | 5xx or unclassified upstream failure. | Classification happens in [`adapter/x/apierr`](app/internal/adapter/x/apierr/apierr.go), and it is deliberately defensive. X answers errors with `Content-Type: application/problem+json`, which the underlying `gotwi` client does not recognise as JSON — so the entire problem body lands verbatim in a message field instead of being decoded into typed fields. The mapper therefore concatenates every source the response can carry and matches problem types (`oauth1-permissions`, `usage-capped`, `credits-depleted`, `rate-limit-exceeded`, …) against that haystack, falling back to HTTP status codes when nothing matches. ### Known limitations - **Recent search only.** 7 days back, full stop. Historical/archive search is a different (Enterprise) product and is not wired up. - **Blocking is not exposed.** `x_users_blocked` reads the list; there is no block/unblock tool. - **No streaming endpoints.** Filtered/sampled stream is not implemented. - **No DMs.** - **One account per process.** The keys are process-wide; the key owner is resolved once at startup. Serving several accounts means several processes. - **Pagination is manual.** Tools return `next_token`; nothing walks pages automatically, because every page is billed. --- ## Quick start ### Install Pick whichever fits — all of them give the identical server. **Container** (no Go toolchain needed): ```bash docker pull ghcr.io/role1776/mcp-x:0.1.1 # or :latest to track the newest release ``` Pin an explicit version in anything you rely on. `:latest` moves on every stable release, and a release can add or change tool behaviour; [`server.json`](server.json) pins the same version the MCP Registry advertises. **Prebuilt binary** — grab the archive for your platform from the [latest release](https://github.com/Role1776/mcp-x/releases/latest), unpack it, and put `mcp-x` on your `PATH`. **MCP Bundle** — for clients that install `.mcpb` files, download `mcp-x_