Skip to content
varmcp

mcp-x

Server 02

v0.1.1

mcp-x

Your model, posting as you on X.

The X API v2 exposed to any MCP client as 42 tools across posts, users, lists and media. It authenticates with OAuth 1.0a user context, so every call acts as a real account — the one the keys belong to. x_post_create publishes publicly, x_post_delete is irreversible, and the API bills per request. This is not a sandbox.

Language
Go 1.26+
Transports
stdio, http
Auth
OAuth 1.0a
Licence
MIT

Before you start

The X API costs money

This is the one server here that cannot be run for free, and the cost is not incidental to using it — it shapes how the tools are written. Read this part before wiring it into an agent.
  • Pay-per-use only

    X retired the Free, Basic and Pro subscription tiers for new developers and moved to prepaid credits: you buy a balance in the Developer Console and every request deducts from it in real time.

    Legacy Basic and Pro subscriptions survive only for accounts that already had them. A developer account opened today gets pay-per-use and nothing else.

  • Reads are billed per result

    Not per request. max_results: 100 on x_posts_search costs twenty times what max_results: 5 costs for the same query. Post reads are $0.005 each, user reads $0.010, your own resources $0.001.

    Every read tool's description tells the model to ask for the smallest max_results that answers the question, and the batching tools tell it to batch rather than loop.

  • Writing is the expensive part

    Publishing a post is $0.015, or $0.20 if it contains a URL. Likes, reposts and other interactions are $0.015 each; list and bookmark writes $0.005 to $0.010.

  • Counting is free

    x_posts_count returns match counts bucketed by minute, hour or day for the same query syntax as the search tool, and does not consume the post-read budget. Size a topic with it first, then pay for x_posts_search.

  • Pagination stays manual

    Tools return a next_token and nothing walks pages by itself, because every page is billed and that decision belongs to the caller. X also deduplicates: the same resource fetched twice inside a 24-hour UTC window is billed once.

  • 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. The same is true of the monthly usage cap.

These rates are the ones the README lists from X's official pricing, which it marks as correct at the time of writing — they have changed several times. Check the Developer Console for what you will actually be charged. There is no free tier for a new developer account.

Install

Four ways to get the binary

Pick whichever fits how you already install things. All four end with the same executable.
bash
docker pull ghcr.io/role1776/mcp-x:0.1.1     # or :latest

No Go toolchain needed.

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 pins the same version the MCP Registry advertises.

Credentials

Five values, and one step everybody gets wrong

Four OAuth 1.0a keys from a single X app, plus a directory. The first pair identifies the app, the second identifies the account acting through it. There is no bearer-token mode: every write endpoint and every “me” endpoint needs a user context.
  • The read-only token trap

    Set the app's permissions to Read and Write before you generate the Access Token and Secret. An access token permanently keeps the permissions the app had at the moment it was created, and nothing on the app settings page tells you otherwise.

    If you generated the token while the app was read-only, every write fails forever until you go back to Keys and tokens and regenerate the Access Token and Secret. Regenerating the API key and secret is not the fix. The server checks for this at startup and refuses to start, naming it.

  • Startup verification

    Before registering a single tool the server calls GET /2/users/me once with a 15-second deadline. That proves the keys are valid, catches the read-only token, and caches the key owner's numeric id — every write endpoint is addressed by that id, and looking it 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.

  • So there is no trying it out

    No credentials means no startup, which means no tool list — a client will show a failed connection and nothing else. There is no way to try this server without a funded X developer account. Run the binary with -version to confirm an install short of that.

  • MEDIA_ROOTrequired

    An absolute path to the only directory x_media_upload may read from. It must already exist, and the server checks that at startup rather than failing at the first upload. Anything resolving outside it is refused, symlinks included.

    It has no default on purpose: without the boundary, a model holding this tool could read any file on the host and post it. Choose a dedicated directory.

The four keys 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. A client's env block works too, and takes precedence over the .env file. Full setup in the README.

Tools

42 tools in four groups

Posts, users, lists and media. Every tool carries MCP annotations — readOnlyHint on reads, destructiveHint on anything irreversible — returns a structured JSON payload matching its output schema, and accepts an optional timeout_ms clamped into its group's window. These six carry the parameters worth stating; the rest of the 42 are in the next section.

x_posts_search

Searches recent posts for several queries in parallel. Recent search reaches back seven days only.

queries []string required
Run in parallel, capped at POSTS_MAX_QUERIES (5). 512 characters each at most. Operators go inside the query string: from:, to:, lang:, is:reply, has:media, url:, conversation_id:, quoting, grouping and - for NOT.
max_results int = 10
Posts per query, 1..100. Every one of them 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].

x_posts_count

Counts matches per query, bucketed over time. Does not spend post reads — use it to size a topic before searching.

queries []string required
Same operators as x_posts_search.
granularity string = hour
minute, hour or day.
days int = 7
1..7, the same window.
timeout_ms int64 = 15000
Clamped to [2000, 60000].

x_posts_lookup

Fetches up to a hundred posts by id in one call. Ids that do not resolve come back as `not found` beside the ones that did.

ids []string required
1..100 post ids, capped at POSTS_MAX_IDS (100) — the API's own ceiling. Batch them; do not call once per id.
timeout_ms int64 = 15000
Clamped to [2000, 60000].

x_posts_by_user

Recent posts for several usernames, fetched in parallel, one result entry per username.

usernames []string required
Without the @. Capped at POSTS_MAX_USERNAMES (5). Validated locally against X's 1–15 character rule before anything is spent.
max_results int = 10
Posts per user, 1..100. Billed per post.
exclude []string
replies and/or retweets.
days int = 7
1..7.
timeout_ms int64 = 15000
Clamped to [2000, 60000].

x_post_create

Publishes a post as the account behind the keys. Public, immediate and billed at $0.015, or $0.20 with a link.

text string
280 characters at most. Required unless media_ids is set.
reply_to_id string
Reply to this post.
quote_id string
Quote this post.
media_ids []string
One to four ids from x_media_upload.
poll object
options (2..4) and duration_minutes (5..10080). Cannot be combined with media_ids.
reply_settings string
following, mentionedUsers, subscribers or verified.

x_media_upload

Uploads an image, GIF or video through the chunked INIT, APPEND and FINALIZE flow, and returns a media_id for x_post_create.

path string
A local file. Must sit inside MEDIA_ROOT.
base64 string
File contents instead of a path. Requires mime.
mime string
For example image/jpeg or video/mp4. Required with base64.
category string required
tweet_image (5 MB), tweet_gif (15 MB) or tweet_video (512 MB). The limit is the category's.
timeout_ms int64 = 120000
Clamped to [5000, 600000] — it covers the upload and X's transcoding. Video is transcoded asynchronously, so a slow encode can still come back pending: the id is valid but not yet postable.

The four timeline tools — x_posts_mentions, x_posts_home, x_posts_liked and x_bookmarks_list — share one shape: max_results (1..100), pagination_token and timeout_ms. x_posts_quotes, x_post_liked_by and x_post_reposted_by add a required id. Pagination is explicit throughout: a response carries next_token and you pass it back as pagination_token, because the server will not walk pages on your money by itself.

Search

Writing a query

The operators go inside the query string itself, not into separate parameters — the same syntax x_posts_search and x_posts_count both take. Recent search reaches back seven days and no further; the historical archive is a separate Enterprise product and is not wired up.
Query operators accepted by x_posts_search and x_posts_count.
(space)AND — every term must appear.
ORExplicit OR.
-termNOT. The server appends -is:retweet itself unless include_retweets is set.
( )Grouping.
"exact phrase"Exact phrase.
from:user / to:userBy author, or by the account replied to.
@user / #tagMentions and hashtags.
lang:enLanguage.
is:retweet is:reply is:quote is:verifiedThe kind of post.
has:media has:images has:videos has:linksWhat it carries.
url:example.comLinks to a domain.
conversation_id:123One thread.

A query is 512 characters at most, and up to POSTS_MAX_QUERIES (5) of them run in parallel per call. Each comes back as its own entry with its own status, so one malformed query does not lose the results of the others. Size the topic with x_posts_count before you pay for the posts.

The rest

All forty-two, by group

Reads carry readOnlyHint; anything irreversible carries destructiveHint, so a client can gate it. Everything below is billed — see what it costs, above.
  • Posts, reading11

    x_posts_search and x_posts_count are above. Beyond them: x_posts_lookup (by id), x_posts_by_user, x_posts_mentions (posts mentioning the key owner), x_posts_home (the owner's home timeline), x_posts_quotes, x_posts_liked, x_bookmarks_list, x_post_liked_by and x_post_reposted_by.

  • Posts, writing5

    x_post_create publishes. x_post_delete is irreversible. x_post_like and x_post_unlike, x_post_repost and x_post_unrepost — the likes and reposts are public, and the undo halves are marked destructive.

    Publishing the same text twice in a row is rejected by X as a duplicate, and the server surfaces that as its own message rather than a generic failure.

  • Users9

    x_users_lookup takes up to 100 usernames or 100 ids in one call — one or the other, not both — and returns unresolved names in not_found. x_users_me is the profile behind the keys.

    x_user_followers and x_user_following page through at up to 1000 per call. x_user_follow and x_user_unfollow are public. x_user_mute and x_user_unmute are silent: the other account is not told and can still see you. x_users_muted and x_users_blocked read the owner's own lists.

  • Lists13

    x_list_create (name ≤ 25 characters, description ≤ 100), x_list_update (send only the fields you want changed), x_list_delete (irreversible — list, membership and followers all go), x_list_get, x_lists_owned and x_list_members.

    x_list_posts returns recent posts from the members, billed per post. x_list_member_add and x_list_member_remove work by username. x_list_follow and x_list_unfollow apply to public lists only. x_list_pin, x_list_unpin and x_lists_pinned cover the owner's pinned lists.

  • Media4

    x_media_upload is above; the other three are the pieces of the chunked flow it drives — INIT, APPEND and FINALIZE — with FINALIZE polled while X transcodes.

    A media_id expires after a while and is meant for a single post. If a large video is still encoding the upload returns pending: the id is valid but not yet postable, so retry x_post_create shortly rather than re-uploading.

Blocking and unblocking are deliberately not exposed: x_users_blocked reads the list and there is no tool to change it. There are no DMs and no streaming endpoints either. The full table, with every parameter, is in the README.

Consequences

What it can do to your account

Everything the account can do, minus what was deliberately left out. Worth knowing before an agent holds these tools unattended.
  • It acts as you

    OAuth 1.0a user context means there is no separation between the model's actions and the account's. A post is a real post, a follow is a public follow, a delete cannot be undone. Irreversible tools carry destructiveHint so a client can gate them, but the server does not ask for confirmation itself.

  • Recent search only

    Seven days back, full stop — the historical archive is a different, Enterprise product and is not wired up. Blocking is not exposed either, and there are no DMs and no streaming endpoints.

  • The HTTP transport has no authentication

    Anyone who can reach the endpoint can publish, delete and follow as your account, on your credits. Bind it to localhost or put it behind an authenticating reverse proxy. Never expose it to the open internet.

  • One account per process

    The keys are process-wide and the key owner is resolved once at startup. Serving several accounts means several processes.

Errors

What it says when it fails

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 corrects the call itself. They are written for a model rather than for a log reader, so the ones that must not be retried say so in the message.
The messages mcp-x returns, and what each one means.
the access token is read-onlyThe trap above. Regenerate the Access Token and Secret after setting the app to Read and Write. Do not retry.
the monthly usage cap … is reachedNothing works until it resets. Do not retry.
the project has no X API credits leftCredits are exhausted and do not reset — top up in the Console. Do not retry.
rate limit exceededThe window resets in about fifteen minutes. Back off.
X rejected the credentialsBad or revoked keys; check all four.
not authorized for this resourceProtected, deleted, or somebody else's.
X rejected this as a duplicateThe same text was posted recently. Change it.
user / post / list not foundSelf-explanatory.
X failed to process the uploaded mediaTranscoding failed; re-encode the file.
media exceeds the size limit5 MB image, 15 MB GIF, 512 MB video.
the path is outside the directory …A MEDIA_ROOT violation, symlinks included.
post text exceeds 280 charactersCaught locally, before a request is spent.
a post needs text unless it carries mediaAlso local.
a poll needs 2 to 4 options …Duration between 5 and 10080 minutes.
a post carries at most 4 media itemsAlso local.
too many ids in one callThe limit is 100.
invalid username1 to 15 letters, digits or underscores. Local.
every query failedAll items failed; the X API may be unreachable.
the call timed outRetry with a larger timeout_ms, or fewer inputs.
the X API is unavailableA 5xx, or an upstream failure that matched nothing.

Classification is deliberately defensive. X answers errors with Content-Type: application/problem+json, which the underlying gotwi client does not recognise as JSON, so the whole problem body arrives verbatim in one message field instead of decoded fields. The mapper 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.

Connect

Point a client at it

Five values have to reach the process, from a .env file passed with -env or from the client's own env block. Variables already in the environment win over the file.
bash
claude mcp add mcp-x \
  -e X_API_KEY=... \
  -e X_API_KEY_SECRET=... \
  -e X_ACCESS_TOKEN=... \
  -e X_ACCESS_TOKEN_SECRET=... \
  -e MEDIA_ROOT=/absolute/path/to/media \
  -- /absolute/path/to/mcp-x

The -- separates the server's own command from the flags above it. Or point it at a file instead: -- /absolute/path/to/mcp-x -env /absolute/path/to/.env.

If a client only says “Connection closed”, run the binary by hand with the same environment: under stdio the server's stderr belongs to the client and most clients discard it. It prints exactly what is wrong — which variables are missing, that the keys were rejected, or that the access token is read-only — and exits. Almost every failed install is one of those three.

Architecture

How it is put together

A clean, layered structure with dependencies pointing inward toward the domain, each layer talking to the next through interfaces. The Go module lives in app/, which is why the release tags do not name it.
  • The domain refuses invalid values

    NewPostID, NewUserID, NewUsername and NewDraft validate on construction, so a malformed id or a 300-character post is rejected locally — before it costs a request. Ids are checked against the snowflake format and usernames against X's 1–15 [A-Za-z0-9_] rule, in one shared format package.

  • One error vocabulary

    Adapters convert X's problem responses into the sentinels in domain/x/errors.go, and the transport layer is the only place that turns a sentinel into human text. That is why the same failure reads identically no matter which of the 42 tools produced it.

  • Fan-out lives in the usecase layer

    The parallel tools return one entry per query, username or id, each with its own status, so a single bad input does not lose the results that worked. A call fails outright only when the input is rejected before any work starts, or when every item in it fails.

  • Limits are resolved per request

    Rather than cross-checked at startup. An omitted or non-positive value falls back to its DEFAULT_*, then is clamped into [MIN_*, MAX_*]; if MIN_* exceeds MAX_*, the maximum wins. Misconfiguration degrades to a working server instead of a failed start — silently, which is the trade-off.

A tool call travels transport → usecase → adapter → gotwi → the X API, and the answer comes back the same way: the transport layer maps errors to isError text, the usecase layer validates, resolves limits, fans out and applies timeouts. Contributions are expected to come with tests; make test, make cover and go -C app vet ./... are the gates. CONTRIBUTING.md has the rest.

Configuration

Environment variables

Everything is configured through environment variables, and each value is validated before startup: a missing required key, or a non-numeric or non-positive number, is a startup error naming the variable you actually set. Each group carries its own limits, so a slow media upload cannot be capped by a search timeout.

Required

5
Environment variables in the Required group, with defaults.
EnvDefaultNotes
X_API_KEYOAuth 1.0a consumer key.
X_API_KEY_SECRETOAuth 1.0a consumer secret.
X_ACCESS_TOKENUser access token. Generate it after setting Read and Write.
X_ACCESS_TOKEN_SECRETIssued with the access token.
MEDIA_ROOTAbsolute path to the only directory x_media_upload may read from. Must exist and be a directory; both are checked at startup.

MCP server

3
Environment variables in the MCP server group, with defaults.
EnvDefaultNotes
MCP_TRANSPORTstdiostdio or http.
MCP_NAMEmcp-xServer name advertised to clients.
MCP_PATH/mcpHTTP route (http transport only).

HTTP server and client

5
Environment variables in the HTTP server and client group, with defaults.
EnvDefaultNotes
SERVER_PORT8080
SERVER_READ_TIMEOUT60s
SERVER_WRITE_TIMEOUT60s
MAX_IDLE_CONNS_PER_HOST100Connection pooling toward the X API.
X_DEBUGfalseLog raw gotwi requests. Verbose, but useful when an X error makes no sense.

Posts limits

10
Environment variables in the Posts limits group, with defaults.
EnvDefaultNotes
POSTS_MAX_QUERIES5Queries per x_posts_search or x_posts_count call.
POSTS_MAX_USERNAMES5Usernames per x_posts_by_user call.
POSTS_MAX_IDS100Ids per x_posts_lookup; the API's own ceiling.
POSTS_DEFAULT_RESULTS10
POSTS_MAX_RESULTS100
POSTS_DEFAULT_DAYS7
POSTS_MAX_DAYS7Recent search cannot look back further.
POSTS_DEFAULT_TIMEOUT_MS15000
POSTS_MIN_TIMEOUT_MS2000
POSTS_MAX_TIMEOUT_MS60000

Users and lists limits

12
Environment variables in the Users and lists limits group, with defaults.
EnvDefault
USERS_MAX_USERNAMES100
USERS_MAX_IDS100
USERS_DEFAULT_RESULTS100
USERS_MAX_RESULTS1000
USERS_DEFAULT_TIMEOUT_MS15000
USERS_MIN_TIMEOUT_MS2000
USERS_MAX_TIMEOUT_MS60000
LISTS_DEFAULT_RESULTS25
LISTS_MAX_RESULTS100
LISTS_DEFAULT_TIMEOUT_MS15000
LISTS_MIN_TIMEOUT_MS2000
LISTS_MAX_TIMEOUT_MS60000

Media limits

5
Environment variables in the Media limits group, with defaults.
EnvDefaultNotes
MEDIA_CHUNK_BYTES41943044 MB. The APPEND endpoint caps a segment at 5 MB.
MEDIA_POLL_INTERVAL_MS2000How often FINALIZE status is polled while X transcodes.
MEDIA_DEFAULT_TIMEOUT_MS120000
MEDIA_MIN_TIMEOUT_MS5000
MEDIA_MAX_TIMEOUT_MS60000010 minutes, for large video.

Logging

1
Environment variables in the Logging group, with defaults.
EnvDefaultNotes
LOG_MODElocallocal → text handler at debug level; prod → JSON handler at info level. Logs go to stderr — they must, since stdout carries the MCP protocol.

Limits are reconciled per request rather than cross-checked at startup: an omitted or non-positive value falls back to its DEFAULT_*, then is clamped into [MIN_*, MAX_*], and if MIN_* exceeds MAX_* the maximum wins. The values the API itself bounds are additionally capped at its ceiling, so a typo cannot produce a request X will reject. The trade-off is that misconfiguration degrades silently — POSTS_MAX_RESULTS=1 quietly shrinks every response instead of failing the start. Full reference in the README.

← Back to the indexio.github.Role1776/mcp-x