x-mcp
# x-mcp
MCP server for **posting to X (Twitter)** through a saved browser session (Playwright + stealth), plus a set of **read scripts** that drive the same session against X's internal GraphQL API.
The MCP surface is write-only by design. Reading X does not need an MCP round-trip and is better done in bulk at the terminal, which is what the scripts in `scripts/` do. The server does the one thing that genuinely needs a browser inside an agent loop: publishing something and confirming it actually landed.
No API keys, no developer account, no paid API tier. The identity is a browser session you log into yourself.
> Not affiliated with, endorsed by, or connected to X Corp. This drives your own logged-in account through a normal browser. Read the [Notes](#notes) before using it.
---
## How it works
X serves its timelines and its mutations from an internal GraphQL API. Scraping the rendered DOM means chasing class names that change every few weeks, so this does not do that. Every page gets a small init script that wraps `fetch` and `XMLHttpRequest` and keeps the JSON the X web app itself receives; the code then walks that JSON for anything that looks like a tweet, in whatever envelope it arrived (a timeline entry, a visibility wrapper, a module item, a quoted tweet).
Two useful consequences. Long posts come back whole out of `note_tweet` instead of truncated at 280 characters. And a post is confirmed by reading X's own `CreateTweet` response for the new tweet's id, rather than sleeping two seconds and assuming it worked.
Chromium runs headed (non-headless) throughout. X blocks headless browsers.
---
## Prerequisites
- Python 3.11+
- An X account
- Google Chrome installed. The code drives your real Chrome rather than Playwright's bundled build, which identifies itself as "Google Chrome for Testing" and is easier for X to flag. Without Chrome it falls back to the bundled build and says so on stderr.
---
## 1. Install
```bash
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/python -m playwright install chromium
```
---
## 2. Save a browser session
There are two ways. **Prefer the first one.**
### a. Import cookies from a browser you are already signed into (recommended)
```bash
.venv/bin/python scripts/import_session.py
```
Logging in _through_ an automated browser is the step X flags, and a failed attempt can get the account's logins temporarily limited. If you are already signed into X normally, this skips that step entirely. The script asks for two cookie values, without echoing them, and writes the session file directly.
To find them, in the browser where you are already signed in: open x.com, press F12 (or Cmd+Option+I), go to **Application** (Chrome) or **Storage** (Firefox) -> **Cookies** -> `https://x.com`, and copy the values of `auth_token` and `ct0`.
`auth_token` is a full credential for the account, equivalent to a password. It is prompted for hidden so it stays out of your shell history, and the session file is written `chmod 600`.
Add `--verify` to open a browser afterwards and confirm the session works.
### b. Log in through the automated browser
```bash
.venv/bin/x-mcp-setup
```
A Chrome window opens on x.com. Log in normally, including whatever 2FA X asks for. Nothing is typed for you and no password is stored. When the home timeline appears, the cookies are saved to `session.json` (gitignored) and the window closes.
This drives your **real installed Google Chrome** (`channel="chrome"`), not Playwright's bundled build, and keeps a persistent profile under `profiles/<account>/` so repeat runs look like a returning browser rather than a fresh one. Playwright's bundled binary identifies itself as "Google Chrome for Testing" and is much easier to flag; if real Chrome cannot be launched the code says so loudly on stderr rather than quietly falling back.
If X answers with "We've temporarily limited your login", stop. Wait it out rather than retrying, since repeated attempts compound the flag, and use method (a) instead.
### Either way
For a second account, add `--account <name>`; that reads and writes `session-<name>.json` instead. Every read script takes the same `--account`, and every MCP tool takes an `account` argument. `X_ACCOUNT` sets the default when the argument is left empty.
---
## 3. Configure your AI tool
### Claude Code
```bash
claude mcp add --scope user x /path/to/x_mcp/.venv/bin/x-mcp
```
### Generic stdio config (Qwen Code, Cursor, etc.)
```json
{
"mcpServers": {
"x": {
"command": "/path/to/x_mcp/.venv/bin/x-mcp"
}
}
}
```
---
## Available tools
| Tool | Description |
| ----------------- | ----------------------------------------------------------------------------- |
| `x_check_session` | Check whether the saved session is live, and which handle it is logged in as. |
| `x_submit_post` | Publish a new standalone post. |
| `x_submit_reply` | Reply to a tweet, by URL or bare id. |
| `x_submit_quote` | Quote-post a tweet with added text. |
| `x_submit_repost` | Repost a tweet with no added text. |
All of them drive a real (headed) Chromium window with the saved session, so they behave like a human posting - no API keys, no OAuth app. Every tool takes an optional `account`.
Run `x_check_session` first: it reports whether the session is live and posts nothing.
### What the posting tools guarantee
- **The target is verified before anything is typed.** A reply, quote or repost navigates to the tweet and confirms that exact id is present in the captured GraphQL. If it cannot be confirmed, the tool raises rather than typing into whatever page it landed on.
- **Success is observed, not assumed.** A post, reply or quote returns the new tweet's real id and URL, read out of X's `CreateTweet` response. A repost is confirmed by the button flipping to its unreposted state.
- **Ambiguity is reported as ambiguity.** If the action cannot be confirmed the result is `{"status": "unverified", ...}` with a screenshot path, never a hopeful `"posted"`. Do not retry an unverified result automatically: a retry on an ambiguous outcome double-posts, or undoes a repost that did land.
- **Every tool publishes publicly and immediately.** There is no draft state. In an MCP client, the client's own approval prompt is the confirmation step.
---
## Reading X (`scripts/`)
Every script opens a headed Chromium window with the saved session and reads the captured GraphQL. Run them with the venv Python.
| Script | What it does |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `scripts/read_user.py <handle> [...]` | Recent posts from one or more accounts. `--with-replies`, `--limit`, `--max-age-h`. |
| `scripts/search.py "<query>"` | A search, Latest tab by default (`--top` for Top). X operators work: `from:`, `min_faves:`, quoted phrases, OR. |
| `scripts/read_thread.py <url-or-id> [...]` | A post plus its replies, ranked by engagement. The context you need to reply well. |
| `scripts/digest.py` | One pass over a worklist of accounts and searches, ranked, skipping anything already handled. |
| `scripts/import_session.py` | Build a session from cookies copied out of a browser you are already signed into. |
| `scripts/setup_session.py` | Same as `x-mcp-setup`, for running from the repo without the console script. |
Example:
```bash
.venv/bin/python scripts/read_user.py nasa esa --max-age-h 48
.venv/bin/python scripts/search.py '"launch window" min_faves:50' --max-age-h 24
.venv/bin/python scripts/read_thread.py https://x.com/nasa/status/1234567890123456789
```
### The digest
`digest.py` reads `targets.json` (gitignored). Copy the template and edit it:
```bash
cp targets.example.json targets.json
```
Three tiers, walked in order:
1. `always_reply` - accounts whose every new post you want to answer.
2. `ecosystem` - larger accounts worth landing a reply under.
3. `queries` - open search, filtered by `min_followers_for_open_search` so you get posts with an audience rather than noise.
```bash
.venv/bin/python scripts/digest.py --per-tier 10 --max-age-h 72
# after you reply to something, so it stops coming back
.venv/bin/python scripts/digest.py --replied 1234567890123456789
.venv/bin/python scripts/digest.py --ignore 1234567890123456789
```
State lives in `state/` (gitignored): `replied.json`, `ignored.json`.
---
## Bundled agent skill
`.claude/skills/x-outreach/SKILL.md` ships with the repo. Open the repo in Claude Code and it loads automatically; to make it available from anywhere:
```bash
ln -s "$(pwd)/.claude/skills/x-outreach" ~/.claude/skills/x-outreach
```
It tells an agent which surface to use for what (reading is the CLIs, posting is the MCP), the approval rule for anything that publishes, what to do with an `unverified` result, and the bar a reply has to clear. Worth reading yourself even if you never point an agent at this.
---
## Compatibility
Verified working against X in **September 2026**.
This reads X's internal GraphQL, which is not a public API and changes without notice. It is built to survive the common kinds of change: responses are walked for tweet objects rather than parsed by a fixed path, and the old field locations are kept as fallbacks. Field moves still happen. A recent one relocated user fields out of `legacy` into `core`, `profile_bio` and `relationship_counts`, which silently zeroed every follower count until the parser learned both shapes.
When something does break, it surfaces in one of three ways rather than as bad data:
- A read raises instead of returning an empty list.
- A posting tool raises naming the selector it looked for, with a screenshot under `state/`.
- A post that cannot be confirmed returns `"status": "unverified"` rather than a hopeful `"posted"`.
If a field goes quietly empty rather than raising, that is a bug worth reporting.
---
## HTTP transport (Docker)
The server speaks stdio by default. Set `MCP_TRANSPORT=streamable-http` (plus `MCP_HOST` / `MCP_PORT`, default port 8201) to run it as an HTTP MCP server; the included Dockerfile does this under `xvfb-run`.
---
## Notes
- **This automates your own logged-in account, which X's terms restrict.** Your account is the thing at risk. Keep the volume human, and do not point this at anything resembling bulk posting.
- X rate-limits aggressive reading. If harvesting starts returning nothing, stop for an hour rather than retrying in a loop.
- One browser window per run, targets read sequentially. A digest over ten accounts and five searches takes a few minutes. That pacing is deliberate.
- Reads fail loudly. A login redirect or an empty capture raises instead of printing an empty list, so a dead session never looks like a quiet day.
- The DOM selectors (`tweetTextarea_0`, `tweetButtonInline`, `retweet`) are X's published test ids. If X changes them, the tools raise with the selector they looked for and a screenshot under `state/`, rather than clicking something else.
- One account per session file; the session file is the identity.
---
## Layout
```
src/x_mcp/session.py session files, browser context, the capture script
src/x_mcp/tweets.py parsing captured GraphQL into one flat tweet shape
src/x_mcp/post.py the posting client
src/x_mcp/server.py the MCP server
scripts/ the read CLIs, sharing the same installed package
.claude/skills/ the bundled agent skill
```
---
## License
MIT. See [LICENSE](LICENSE).
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: creating a new post, replying, quoting, reposting, and checking session state. There is no overlap or ambiguity between them.
All submission tools follow the consistent x_submit_* pattern (post, reply, quote, repost), and the session check uses x_check_session. The naming is predictable and uniform.
With only 5 tools, the server is well-scoped for its purpose of publishing to X. Each tool serves a necessary function without unnecessary bloat or gaps.
The tool set covers the core submission actions (post, reply, quote, repost) and session verification, which aligns with its stated purpose. Missing features like deleting posts or fetching timelines are not critical for a submission-focused server, but a minor gap exists for listing or managing existing content.