Skip to main content
Glama
armatrix

twitter-mcp

by armatrix
README.md
# twitter-mcp + xdata

X (Twitter) data access for Claude Code, split across two front ends over one set of adapters:

- **`xdata` CLI** — all content reads. This is the entry point you want.
- **MCP server** — writes, post-volume counts, and usage only.

Zero compilation — edit and run instantly with Bun.

## Why reads live in the CLI, not in MCP

Both data providers bill **per returned record**, and an MCP tool result lands in the model's
context *in full*. One 20-post search is 20–40k tokens of JSON. From a shell the same call can be
trimmed, piped through `jq`, or written straight to disk with only a summary coming back.

Measured, not estimated:

| | tools | schema in context | a 20-post search in context |
|---|---|---|---|
before (reads in MCP) | 21 | ~1,898 tok | 20,000–40,000 tok |
now (reads in CLI) | 9 | ~986 tok | ~600 tok (`--out`: ~30 tok) |

The 900-token schema saving is the small half. The point is that large payloads no longer have a
path into the conversation at all.

## Architecture

```
┌───────────────────────────┐   ┌──────────────────────────────────────┐
│  xdata CLI                │   │  MCP server (twitter-mcp v2.0)       │
│  all content reads        │   │  writes · counts · usage · feeds     │
└─────────────┬─────────────┘   └──────────────┬───────────────────────┘
              │                                │
              ▼                                ▼
   ┌──────────────────────┐         ┌────────────────────┐
   │ ReaderClient  (port) │         │ WriterClient       │  OAuth 1.0a
   ├──────────────────────┤         │ CookieClient       │  ct0 + auth_token
   │ primary              │         │ CountsClient       │  OAuth2 app-only
   │  twitterapi.io       │         └────────────────────┘
   │  $0.00015/post       │
   │       │ on 401/402/429/5xx or an HTTP-200 `status:error`
   │       ▼              │
   │ fallback             │   every fallback is tagged in the response —
   │  official X API v2   │   a silent switch would hide a 33x price jump
   │  $0.005/post         │
   └──────────────────────┘
```

### Which channel for what

| Need | Channel | Cost shape |
|---|---|---|
Content (search, timelines, replies, profiles, graph) | twitterapi.io | **$0.00015/post**, $0.00018/profile |
Same, when the primary is out of credits or down | official v2 (auto, tagged) | $0.005/post, $0.010/user — 33x |
**Volume over time** (trends, ranking, spotting peaks) | official `counts` | **$0.005 per _request_** — any match volume, and it does not consume the 3M post-read cap |
Writes (post/like/retweet/delete) | official OAuth 1.0a | $0.015/post ($0.20 with a link) |
Replies the Free tier blocks (403) | cookie GraphQL | free |

**The cheap pattern is two-stage**: `counts` first to find which query and which window matter
(effectively free), then pull posts for only that window. Large blind searches are the expensive
mistake.

## Setup

```bash
git clone https://github.com/armatrix/twitter-mcp.git && cd twitter-mcp && bun install
mkdir -p ~/.twitter-mcp && cp config.example.json ~/.twitter-mcp/config.json && chmod 600 ~/.twitter-mcp/config.json
```

`~/.twitter-mcp/config.json` is the single source of truth for every credential:

```json
{
  "reader":   { "provider": "twitterapi.io", "api_key": "new1_..." },
  "writer":   { "provider": "twitter-official", "api_key": "...", "api_secret_key": "...",
                "access_token": "...", "access_token_secret": "..." },
  "official": { "bearer": "AAAA..." },
  "cookie":   { "ct0": "...", "auth_token": "..." },
  "defaults": { "timeline_count": 20, "search_count": 20 }
}
```

- `reader.api_key` — [twitterapi.io](https://twitterapi.io). Optional: without it every read falls
  back to the official API and is tagged as such (33x the price, same capability).
- `writer.*` — X Developer Portal → your App → Keys and Tokens.
- `official.bearer` — **derive it, don't go hunting for it.** OAuth 1.0a is rejected by `counts`,
  location trends, `usage` and filtered stream; an app-only bearer is one call away from the
  consumer key/secret you already have:

  ```bash
  curl -s -u "<consumer_key>:<consumer_secret>" \
       -d 'grant_type=client_credentials' https://api.x.com/oauth2/token
  ```

- `cookie.*` — DevTools → Application → Cookies → `x.com` → copy `ct0` and `auth_token`.
  Optional; only powers the reply fallback. `ct0` rotates every few hours.

Install the CLI and register the MCP server:

```bash
ln -sf "$PWD/bin/xdata" ~/.local/bin/xdata     # or point a wrapper at src/cli.ts
claude mcp add twitter -- bun run "$PWD/src/index.ts" -config ~/.twitter-mcp/config.json
```

## CLI

```
xdata usage                                    # both providers: balance, spend, cap
xdata counts '<query>' --day|--hour [--all]    # per-request billing → trends & ranking
xdata search '<q> min_faves:30' --top -n 20    # min_faves is server-side (official v2 lacks it)
xdata user|about|timeline|mentions <handle>
xdata replies|quotes|thread|retweeters <tweetId>
xdata tweets <id,id,...>                       # batch by id
xdata batch-users <id,id,...>                  # bulk profiles, cheaper per user
xdata followers|followings <handle>
xdata follower-ids <handle>                    # IDs only: 0.45cr vs 18cr per profile — 40x cheaper
xdata verified-followers <handle>              # $0.3/1000
xdata usersearch <query>                       # no official equivalent exists
xdata follows <a> <b>                          # mutual-follow check (100cr)
xdata article <tweetId>                        # long-form body (no official equivalent)
xdata trends [woeid]
```

Output control — this is where the token savings are:

```
--out FILE   full JSON to disk, one summary line to stdout
--json       raw          --jsonl   one record per line (pipe to jq)
-n N         max records  -w N      text column width      -q  no summary line
```

Every command prints `cost≈`, because both providers bill per record and it is otherwise invisible
until the dashboard catches up.

## MCP tools (9)

| Tool | Channel | Notes |
|---|---|---|
`post_tweet` | OAuth 1.0a → cookie | Up to 4 images; auto-falls back to cookie auth on a reply 403 |
`like_tweet` `unlike_tweet` `retweet` `delete_tweet` | OAuth 1.0a | |
`get_tweet_counts` | official app-only | Per-request billing; counts only (no text/authors) |
`get_api_usage` | official app-only | Post reads against the monthly cap + estimated spend |
`get_for_you_feed` `get_following_feed` | cookie | The authenticated account's own timelines |

There are deliberately **no content-read tools here**. The server says so on startup.

### Reply fallback

```
post_tweet(text, reply_to_tweet_id) → OAuth 1.0a POST /2/tweets
  ├─ 200 → done (method: "oauth")
  └─ 403 (Free tier reply restriction) → cookie GraphQL CreateTweet (method: "cookie")
                                       → or an error if no cookie is configured
```

## Endpoint index

`docs/endpoint-index.md` — 264 endpoints across both providers, generated from their machine-readable
specs (`api.x.com/2/openapi.json` v2.168 / 157 paths + twitterapi.io / 76 paths), grouped by function
with auth requirements and billing per row. Consult it before assuming a capability is missing.

⚠️ **A spec is a lead, not a fact.** `/twitter/user/tweet_timeline` is documented with a top-level
`tweets` array but actually answers `data.tweets`; the adapter accepts both, because reading only one
shape yields an empty array indistinguishable from "no results".

## Known limitations

| Issue | Reality |
|---|---|
`/twitter/user/following` looks broken | It isn't a route — the path is `followings` (plural). Wrong URLs answer HTTP 200 `status:error msg:"user not found"` |
`last_tweets` ignores `count` | No such parameter; a page is always ~20 and you are billed per returned tweet. Narrow with `cursor`, and prefer the stream API for monitoring — the endpoint docs say polling it frequently "will cost you a lot" |
Official `counts`/`trends`/`usage`/stream return 403 | They reject OAuth 1.0a. Use `official.bearer` |
Official v2 has no user search, and no long-form article body | Only twitterapi.io has these |
`firehose` / `sample` streams | 403 — Enterprise tier |
Filtered stream billing | Not in the pricing page; rule slots are visible (`cap_per_client_app: 5`, `cap_per_project: 15`) but per-delivery cost is **unverified** |
Free-tier OAuth blocks replies to non-interacted posts | Cookie fallback handles it |
`ct0` rotates every few hours | Re-copy from the browser when cookie auth starts failing |

## Project structure

```
src/
├── cli.ts                      # xdata — all content reads
├── index.ts                    # MCP server — writes, counts, usage, feeds
├── config.ts
├── clients/
│   ├── reader.ts               # port + router (primary → tagged fallback)
│   ├── reader-twitterapi.ts    # primary adapter
│   ├── reader-official.ts      # fallback adapter (X API v2, OAuth 1.0a)
│   ├── counts.ts               # app-only bearer: counts / trends / usage
│   ├── writer.ts               # OAuth 1.0a writes + media upload
│   └── cookie.ts               # GraphQL via browser session
├── lib/
│   ├── normalize-tweet.ts      # GraphQL → NormalizedTweet
│   └── v2-to-normalized.ts     # X API v2 → NormalizedTweet
└── tools/
    ├── discovery.ts            # get_tweet_counts, get_api_usage, cookie feeds
    └── write.ts                # post/like/unlike/retweet/delete
docs/
├── endpoint-index.md           # both providers, generated from specs
PATCH-reader-dual-channel.md    # why it is built this way + what was learned the hard way
```

## Development

No build step. Edit and restart the server (**stdio servers do not hot-reload — `/mcp` reconnect
after changing anything the server loads**).

```bash
bun run src/index.ts -config ~/.twitter-mcp/config.json
bunx tsc --noEmit
```

## Tech stack

Bun · [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) ·
[oauth-1.0a](https://github.com/nicobytes/oauth-1.0a) · `bun:sqlite` · X internal GraphQL (no deps)

## License

MIT