@mgcrea/mcp-x-api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +114 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +836 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-BeRiqZxj.js +4008 -0
- package/dist/server-BeRiqZxj.js.map +1 -0
- package/package.json +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olivier Louvignes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# @mgcrea/mcp-x-api
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@mgcrea/mcp-x-api)
|
|
4
|
+
[](https://github.com/mgcrea/mcp-x-api/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Model Context Protocol server for the **X (Twitter) API v2** — built for reading and searching posts.
|
|
8
|
+
|
|
9
|
+
> **Unofficial.** Not affiliated with, endorsed by, or supported by X Corp.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **A serious reader.** Post lookup, recent and full-archive search, profiles, user timelines, thread reconstruction, bookmarks and your home timeline — with X's query syntax exposed properly.
|
|
14
|
+
- **Readable output.** X returns posts whose author, quoted post and real URLs live in a separate `includes` sidecar. Every tool here resolves that first, so posts arrive with the handle inline, t.co links expanded, and retweets showing the original text.
|
|
15
|
+
- **Free posting.** `x_compose_post` returns an [`x.com/intent/tweet`](https://docs.x.com/x-for-websites/post-button/guides/web-intent) URL you click. No credentials, no API quota, no cost — and nothing publishes without a human click.
|
|
16
|
+
- **Cost-aware by design.** X is pay-per-use. Every read reports what it cost, repeat reads inside a UTC day are free, and a budget ceiling stops a runaway loop before the request goes out.
|
|
17
|
+
- **Ads, when you ask for it.** Campaigns, line items, targeting, audiences and performance analytics through the [X Ads API](https://docs.x.com/x-ads-api/introduction). Needs its own approval from X and is off unless configured; campaign writes are a second switch again, and anything created starts PAUSED.
|
|
18
|
+
- **Official API only.** No cookie scraping, no password automation, nothing that risks your account.
|
|
19
|
+
|
|
20
|
+
## Cost — read this first
|
|
21
|
+
|
|
22
|
+
**X removed its tiered pricing on 2026-02-06. There is no free tier for new developers**, and much of the ecosystem's documentation still says otherwise. Current list prices:
|
|
23
|
+
|
|
24
|
+
| Action | Price |
|
|
25
|
+
| ------------------------------------------------- | --------- |
|
|
26
|
+
| Read a post | ~$0.005 |
|
|
27
|
+
| Read a user profile | ~$0.010 |
|
|
28
|
+
| Read **your own** data (bookmarks, home timeline) | ~$0.001 |
|
|
29
|
+
| Create a post | ~$0.015 |
|
|
30
|
+
| Create a post **containing a URL** | ~$0.200 |
|
|
31
|
+
| Monthly read cap | 2,000,000 |
|
|
32
|
+
|
|
33
|
+
Credits are prepurchased in the developer console; at zero credits, requests are blocked. Three things follow, and they shape the whole server:
|
|
34
|
+
|
|
35
|
+
1. **`maxResults` defaults to 10, not 50.** A 100-result search is about $0.50.
|
|
36
|
+
2. **Repeat reads are free.** X deduplicates per resource id within a UTC calendar day, so the built-in cache mirrors that exactly — a cache hit is genuinely free, not merely fast.
|
|
37
|
+
3. **Posting should not cost anything.** `x_compose_post` is the default write path and uses a web intent. The paid `x_create_post` stays unregistered unless you opt in twice (`X_API_ALLOW_WRITES=1` **and** `X_API_WRITE_BACKEND=api`).
|
|
38
|
+
|
|
39
|
+
Run `x_count_recent` before a broad search — it returns totals without reading any posts, so it costs nothing and tells you what the search would cost. `x_build_search_query` is likewise free and local.
|
|
40
|
+
|
|
41
|
+
> Prices are X's published list rates, transcribed 2026-07-19. Override them via the config file's `pricing` key if they change. `x_usage_report` estimates locally and is not authoritative — the developer portal is.
|
|
42
|
+
|
|
43
|
+
**Ads is billed elsewhere.** Ads API calls are not metered by X's pay-per-use read pricing, so they cost nothing and never appear in `x_usage_report`. What they manage does: a campaign spends your advertising budget, on X's invoice rather than the API's, and no tool here can see that number. Treat `x_usage_report` as silent on ads rather than as reporting zero.
|
|
44
|
+
|
|
45
|
+
## Security
|
|
46
|
+
|
|
47
|
+
- **Supply chain.** Two runtime dependencies: the MCP SDK and zod. The HTTP client is ~250 lines of `fetch`.
|
|
48
|
+
- **Verified builds.** npm releases carry [provenance](https://docs.npmjs.com/generating-provenance-statements) via OIDC trusted publishing; container images are multi-arch, ship an SBOM, and are signed with [cosign](https://docs.sigstore.dev/cosign/signing/overview/).
|
|
49
|
+
- **Your credentials.** Read from the environment or a config file you control, sent only to `api.x.com` (and `ads-api.x.com` when ads is enabled), never logged. The **one** file this server writes is `tokens.json` (mode 600), and only if you use OAuth — it has to persist a rotating refresh token. Everything else is read-only.
|
|
50
|
+
- **Blast radius.** Paid writes are off by default and _unregistered_ rather than refused, so an agent cannot call what does not exist. The free compose path never publishes without a human clicking Post. Ads writes are a separate switch on the same principle, campaigns and line items are created `PAUSED` unless a call explicitly asks otherwise, and budgets are taken in major currency units — the ×1,000,000 mistake is not expressible.
|
|
51
|
+
- **No scraping.** This server never touches session cookies or your password. Tools that do are a ban risk regardless of how they are marketed.
|
|
52
|
+
|
|
53
|
+
## Configure
|
|
54
|
+
|
|
55
|
+
**The server starts with no configuration at all.** In that state it registers only the tools that need no credentials — `x_compose_post`, `x_validate_post`, `x_build_search_query` and `x_auth_status` — and `x_auth_status` tells you exactly what to set for the rest. It never refuses to start over missing credentials, because an MCP server that exits shows up in the client as a bare `Connection closed` with the explanation swallowed.
|
|
56
|
+
|
|
57
|
+
To read anything, one variable is required:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export X_API_BEARER_TOKEN="..." # console.x.com → your app → Keys and Tokens
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That covers every public read: lookup, search, profiles, timelines — no OAuth needed. See [Getting credentials](#getting-credentials) below, and [.env.example](./.env.example) for the rest.
|
|
64
|
+
|
|
65
|
+
### Getting credentials
|
|
66
|
+
|
|
67
|
+
Create an app at **[console.x.com](https://console.x.com)** — this replaced the old `developer.x.com` portal, and the legacy URL is a common dead end. Sign in, accept the Developer Agreement, then **New App**.
|
|
68
|
+
|
|
69
|
+
Settings that matter:
|
|
70
|
+
|
|
71
|
+
| Setting | Value | Why |
|
|
72
|
+
| ------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
+
| **Type of App** | **Native App** | Native App and Single Page App are _public_ clients — PKCE, no client secret. Web App and Automated App are confidential and issue a secret this server does not want. |
|
|
74
|
+
| **App permissions** | Read _(or Read and write)_ | Read covers search and bookmarks. Changing this later forces every user to re-authorize. |
|
|
75
|
+
| **Callback URI** | `http://127.0.0.1:8723/callback` | Must match byte for byte. X's docs say to use `127.0.0.1`, not `localhost`, for local development. |
|
|
76
|
+
| **Package / Environment** | **Pay-per-use / Production** | See the warning below. |
|
|
77
|
+
|
|
78
|
+
Both credentials appear on the app's **Keys and Tokens** screen: the **Bearer Token** and the **Client ID**.
|
|
79
|
+
|
|
80
|
+
> **The enrollment trap.** An app left in the legacy Free package or the Development environment logs in successfully and then fails _every_ user-context call with `403 client-not-enrolled`. If that happens, open the app at console.x.com and move it to Pay-per-use / Production. X's own `xurl` CLI documents this as the fix.
|
|
81
|
+
|
|
82
|
+
Creating an app and getting credentials appears to be free; **making calls is not** — there has been no free tier since 2026-02-06, so you need prepurchased credits before any read succeeds.
|
|
83
|
+
|
|
84
|
+
**OAuth 2.0** is needed only for bookmarks, your home timeline, and API writes:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
export X_API_CLIENT_ID="..." # the Client ID of a Native App (public PKCE client)
|
|
88
|
+
npx @mgcrea/mcp-x-api login # opens a browser, stores a refresh token (mode 600)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Ads API access
|
|
92
|
+
|
|
93
|
+
The Ads API is a separate product behind a separate approval, even though it uses the same OAuth 2.0 login. Three steps, in order:
|
|
94
|
+
|
|
95
|
+
1. At [console.x.com](https://console.x.com), open the app → **Project Access** → **MANAGE** → select **Ads Project**. This attaches Ads API access to the app id.
|
|
96
|
+
2. Request Ads API access for that app using X's **Ads API Access Form**. Standard Access covers campaigns, creatives, audiences and analytics.
|
|
97
|
+
3. Once approved, run `x-api-mcp login` **again**, then set `X_ADS_ENABLED=1`.
|
|
98
|
+
|
|
99
|
+
> **The regenerate trap.** A token minted _before_ your Ads API approval does not carry the entitlement. It logs in fine, reads posts fine, and then fails every ads call — which reads as a scope problem and is not one. If ads calls fail right after approval, log in again before debugging anything else.
|
|
100
|
+
|
|
101
|
+
Your X user also needs a role on at least one ads account, granted in [ads.x.com](https://ads.x.com) rather than the developer console — the API only ever sees accounts you can already see there.
|
|
102
|
+
|
|
103
|
+
Exercise the write tools against the free sandbox first:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
export X_ADS_ENABLED=1
|
|
107
|
+
export X_ADS_ALLOW_WRITES=1
|
|
108
|
+
export X_ADS_BASE_URL=https://ads-api-sandbox.twitter.com # note: not .x.com, which does not resolve
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Campaigns and line items are created `PAUSED` unless a call passes `activateImmediately: true`, and every budget is given in major currency units — `50` means 50.00, never 50000000.
|
|
112
|
+
|
|
113
|
+
### Config file
|
|
114
|
+
|
|
115
|
+
Instead of environment variables, use `~/.config/x-api/config.json` (camelCase keys, the env names minus the `X_API_` prefix). Environment wins **per field**, so a one-off `X_API_ALLOW_WRITES=0` still overrides a file that says `true`. Unknown keys are an error rather than silently ignored.
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"bearerToken": "...",
|
|
120
|
+
"defaultMaxResults": 10,
|
|
121
|
+
"monthlyBudgetUsd": 25
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Quick start
|
|
126
|
+
|
|
127
|
+
**A. npx**
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"mcpServers": {
|
|
132
|
+
"x-api": {
|
|
133
|
+
"command": "npx",
|
|
134
|
+
"args": ["-y", "@mgcrea/mcp-x-api"],
|
|
135
|
+
"env": { "X_API_BEARER_TOKEN": "..." }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**B. Docker (stdio)**
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"mcpServers": {
|
|
146
|
+
"x-api": {
|
|
147
|
+
"command": "docker",
|
|
148
|
+
"args": ["run", "--rm", "-i", "-e", "X_API_BEARER_TOKEN", "ghcr.io/mgcrea/mcp-x-api"],
|
|
149
|
+
"env": { "X_API_BEARER_TOKEN": "..." }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**C. From source**
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
pnpm install && pnpm build
|
|
159
|
+
X_API_BEARER_TOKEN=... node dist/cli.js
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Inspect the tools**
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
X_API_BEARER_TOKEN=... npx @modelcontextprotocol/inspector node dist/cli.js
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Tools
|
|
169
|
+
|
|
170
|
+
Writes are marked `*`, and `†` means a `confirm: true` argument is required. Tools that do not apply to your configuration are not registered at all.
|
|
171
|
+
|
|
172
|
+
**Posts** — `x_get_post` · `x_get_posts` (batch up to 100 in one request) · `x_get_thread` · `x_get_quotes`
|
|
173
|
+
|
|
174
|
+
**Users** — `x_get_user` · `x_get_users` · `x_get_user_posts` · `x_get_user_mentions`
|
|
175
|
+
|
|
176
|
+
**Search** — `x_search_recent` · `x_count_recent` (free — totals only) · `x_build_search_query` (free — local) · `x_search_all` _(needs `X_API_ENABLE_FULL_ARCHIVE`)_
|
|
177
|
+
|
|
178
|
+
**Compose** — `x_validate_post` (free, local) · `x_compose_post` (free, web intent) · `x_create_post` \*† · `x_delete_post` \*† _(the last two need `X_API_ALLOW_WRITES=1` **and** `X_API_WRITE_BACKEND=api`)_
|
|
179
|
+
|
|
180
|
+
**Timelines** — `x_get_home_timeline` · `x_get_bookmarks` _(need OAuth login; X serves these for your own account only)_
|
|
181
|
+
|
|
182
|
+
**Auth** — `x_auth_status` · `x_auth_login` \* · `x_auth_logout` \*† _(the last two need `X_API_CLIENT_ID`)_
|
|
183
|
+
|
|
184
|
+
**Ads — reads** — `x_ads_get_accounts` · `x_ads_get_funding_instruments` · `x_ads_get_campaigns` · `x_ads_get_line_items` · `x_ads_get_promoted_tweets` · `x_ads_get_targeting_criteria` · `x_ads_search_targeting_options` · `x_ads_get_audiences` · `x_ads_get_stats` · `x_ads_create_stats_job` · `x_ads_get_stats_jobs` · `x_ads_download_stats_job` _(need `X_ADS_ENABLED` and an OAuth login)_
|
|
185
|
+
|
|
186
|
+
**Ads — writes** \* † — `x_ads_create_campaign` · `x_ads_update_campaign` · `x_ads_delete_campaign` · `x_ads_create_line_item` · `x_ads_update_line_item` · `x_ads_delete_line_item` · `x_ads_create_targeting_criterion` · `x_ads_delete_targeting_criterion` · `x_ads_create_promoted_tweet` · `x_ads_delete_promoted_tweet` · `x_ads_set_entity_status` _(need `X_ADS_ALLOW_WRITES=1`)_
|
|
187
|
+
|
|
188
|
+
**Usage** — `x_usage_report` · `x_rate_limit_status`
|
|
189
|
+
|
|
190
|
+
### Reading posts
|
|
191
|
+
|
|
192
|
+
Ask for a post and you get it resolved, not raw:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"post": {
|
|
197
|
+
"id": "1799000000000000001",
|
|
198
|
+
"url": "https://x.com/mgcrea/status/1799000000000000001",
|
|
199
|
+
"author": "@mgcrea (Olivier)",
|
|
200
|
+
"text": "Shipping v2 today https://acme.dev/v2",
|
|
201
|
+
"metrics": { "likes": 88, "reposts": 12, "replies": 3, "quotes": 1, "views": 10400 },
|
|
202
|
+
"quotes": { "id": "1798…", "author": "@acme (Acme Inc)", "text": "v1 was great." },
|
|
203
|
+
"media": ["photo: https://pbs.twimg.com/media/x.jpg (alt: release notes screenshot)"]
|
|
204
|
+
},
|
|
205
|
+
"cost": { "billable_post_reads": 1, "free_from_cache": 0, "estimated_usd": 0.005 }
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The `includes` sidecar, `entities`, `edit_history_tweet_ids` and `author_id` never appear — the join is already done, so the model cannot get it wrong.
|
|
210
|
+
|
|
211
|
+
### Searching without overspending
|
|
212
|
+
|
|
213
|
+
The cheapest workflow is free until the last step:
|
|
214
|
+
|
|
215
|
+
1. `x_build_search_query` — turn a description into `rust (from:a OR from:b) lang:en -is:retweet`, with each operator explained. Local, $0.
|
|
216
|
+
2. `x_count_recent` — how many posts match, and what reading them all would cost. Totals only, $0.
|
|
217
|
+
3. `x_search_recent` — actually read them, at ~$0.005 each.
|
|
218
|
+
|
|
219
|
+
### Posting for free
|
|
220
|
+
|
|
221
|
+
`x_compose_post` validates the draft against X's **weighted** 280-character limit (every URL counts 23 whatever its length; CJK characters and emoji count 2, so 140 Japanese characters is already full) and hands back a URL:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"intent_url": "https://x.com/intent/tweet?text=Shipping+v2+today&url=https%3A%2F%2Facme.dev%2Fv2",
|
|
226
|
+
"opened": true,
|
|
227
|
+
"weighted_length": 41,
|
|
228
|
+
"remaining": 239,
|
|
229
|
+
"cost": { "estimated_usd": 0, "note": "Web intent — no API call, no quota consumed." }
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The URL comes back whether or not a browser could be opened, so Docker and SSH behave identically.
|
|
234
|
+
|
|
235
|
+
**Web intents cannot** attach media, create polls, make native quote posts, or build threads — those need the paid API. Replying to a post _does_ work (`inReplyTo`).
|
|
236
|
+
|
|
237
|
+
## Troubleshooting
|
|
238
|
+
|
|
239
|
+
**`MCP error -32000: Connection closed`** — the server process died on startup. It does _not_ do this for missing credentials (see [Configure](#configure)), so check, in order:
|
|
240
|
+
|
|
241
|
+
1. The command actually resolves. `@mgcrea/mcp-x-api` must be published for the `npx` form to work; while developing, point at a built file: `"command": "node", "args": ["/absolute/path/to/dist/cli.js"]`. A relative `./dist/cli.js` depends on the client's working directory.
|
|
242
|
+
2. You ran `pnpm build` — `dist/cli.js` has to exist.
|
|
243
|
+
3. Run it by hand to see stderr, which MCP clients swallow: `X_API_BEARER_TOKEN=... node dist/cli.js`. A config error prints one readable line; add `X_API_DEBUG=1` for the stack.
|
|
244
|
+
|
|
245
|
+
**Only four tools show up** — no credentials are configured. Call `x_auth_status`; it returns the setup steps.
|
|
246
|
+
|
|
247
|
+
**I want OAuth but see no way in** — OAuth needs an app you register. See [Getting credentials](#getting-credentials): create a **Native App** at console.x.com, set `X_API_CLIENT_ID` to its Client ID, register the callback, then run `npx @mgcrea/mcp-x-api login` (or call `x_auth_login`). There is no way to log in without a client id — X has nothing to authorize against.
|
|
248
|
+
|
|
249
|
+
**`403 client-not-enrolled` right after a successful login** — the app is in the legacy Free package or the Development environment. Move it to **Pay-per-use / Production** at console.x.com. Nothing about your token or scopes is wrong.
|
|
250
|
+
|
|
251
|
+
**Bookmarks or the home timeline say they cannot identify your account** — X's `/2/users/me` is unreliable, and login tolerates it failing. The server retries it lazily on first use and caches the result, so this usually resolves itself; if it persists, it is normally the enrollment trap above rather than a login problem.
|
|
252
|
+
|
|
253
|
+
## Notes
|
|
254
|
+
|
|
255
|
+
- **Recent search reaches back 7 days.** `x_get_thread` inherits that limit: an older conversation returns only its root post. Full-archive search (back to March 2006) needs a paid tier and `X_API_ENABLE_FULL_ARCHIVE=1`, and is capped at one request per second.
|
|
256
|
+
- **Bookmarks and the home timeline are self-only.** X does not serve anyone else's, so those tools take no user argument — the id comes from your token.
|
|
257
|
+
- **Rate limits are per endpoint and per credential**, not per plan: recent search allows 450 requests / 15 min app-only vs 300 with a user token. `x_rate_limit_status` shows the headroom X last reported.
|
|
258
|
+
- **Search asks for at least 10 results.** X's minimum for `max_results` is 10, so a request for 3 fetches 10 and returns 3. Billing follows what X actually returned.
|
|
259
|
+
- **`x_usage_report` counts this process only.** It is not persisted across restarts and does not know about spend from other clients.
|
|
260
|
+
|
|
261
|
+
## Develop
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
pnpm install
|
|
265
|
+
pnpm test # vitest, fully offline — every test injects fetch
|
|
266
|
+
pnpm typecheck
|
|
267
|
+
pnpm lint && pnpm format:check
|
|
268
|
+
pnpm build
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Tests never touch the network: `createServer` accepts an injectable `fetch`, `logger` and `tokenProvider`, and `test/tools.test.ts` drives the real server through the MCP SDK's in-memory transport.
|
|
272
|
+
|
|
273
|
+
### Publish
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pnpm dlx release-it # tags vX.Y.Z; CI publishes to npm and GHCR
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Verify a release
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npm view @mgcrea/mcp-x-api --json | jq .dist.attestations
|
|
283
|
+
cosign verify ghcr.io/mgcrea/mcp-x-api:latest \
|
|
284
|
+
--certificate-identity-regexp 'https://github.com/mgcrea/mcp-x-api/.*' \
|
|
285
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## License
|
|
289
|
+
|
|
290
|
+
MIT © [Olivier Louvignes](https://github.com/mgcrea)
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { B as setupInstructions, I as hasAdsAccess, L as hasApiCredentials, R as loadConfig, T as startLoginFlow, a as openInBrowser, q as BUILD_INFO, r as createServer } from "./server-BeRiqZxj.js";
|
|
3
|
+
import { ZodError } from "zod";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
5
|
+
//#region src/cli.ts
|
|
6
|
+
const stderrLogger = {
|
|
7
|
+
debug: (...args) => {
|
|
8
|
+
if (process.env.X_API_DEBUG) console.error("[x-api-mcp]", ...args);
|
|
9
|
+
},
|
|
10
|
+
warn: (...args) => console.error("[x-api-mcp]", ...args),
|
|
11
|
+
error: (...args) => console.error("[x-api-mcp]", ...args)
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The ads segment of the startup banner. It spells out the environment because
|
|
15
|
+
* "LIVE+writes" scrolling past is the last chance anyone has to notice before
|
|
16
|
+
* an agent changes a campaign that spends real money.
|
|
17
|
+
*/
|
|
18
|
+
const describeAds = (config) => {
|
|
19
|
+
if (!hasAdsAccess(config)) return "off";
|
|
20
|
+
return `${/ads-api-sandbox\./.test(config.adsBaseUrl) ? "SANDBOX" : "LIVE"}${config.adsAllowWrites ? "+writes" : " read-only"}`;
|
|
21
|
+
};
|
|
22
|
+
const describeAuth = (config) => {
|
|
23
|
+
const parts = [];
|
|
24
|
+
if (config.bearerToken) parts.push("bearer");
|
|
25
|
+
if (config.clientId) parts.push("oauth2");
|
|
26
|
+
return parts.length > 0 ? parts.join("+") : "none";
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `login` / `logout` / `status` run and exit; anything else starts the stdio
|
|
30
|
+
* server. Handled before the transport is created, so these never write to the
|
|
31
|
+
* protocol channel.
|
|
32
|
+
*/
|
|
33
|
+
const runSubcommand = async (command) => {
|
|
34
|
+
if (![
|
|
35
|
+
"login",
|
|
36
|
+
"logout",
|
|
37
|
+
"status"
|
|
38
|
+
].includes(command)) return false;
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const { store, tokenProvider } = createServer({
|
|
41
|
+
config,
|
|
42
|
+
logger: stderrLogger
|
|
43
|
+
});
|
|
44
|
+
if (command === "status") {
|
|
45
|
+
const status = tokenProvider.describe();
|
|
46
|
+
console.error(`ads: ${describeAds(config)}${hasAdsAccess(config) ? ` (${config.adsBaseUrl})` : ""}`);
|
|
47
|
+
console.error(`app-only bearer: ${status.app ? "configured" : "not configured"}`);
|
|
48
|
+
console.error(status.user.authenticated ? `oauth2 user: @${status.user.username ?? "?"} (scopes: ${status.user.scopes.join(", ")}, expires ${new Date(status.user.expiresAt).toISOString()})` : `oauth2 user: not authenticated — ${status.user.reason}`);
|
|
49
|
+
console.error(`token file: ${config.tokenFile}`);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (command === "logout") {
|
|
53
|
+
store.clear();
|
|
54
|
+
console.error(`Removed ${config.tokenFile}. The app-only Bearer token is unaffected.`);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!config.clientId) {
|
|
58
|
+
console.error(`Cannot log in: X_API_CLIENT_ID is not set. Create an OAuth 2.0 app in the X developer portal, enable PKCE, and register this exact callback URL: ${config.redirectUri}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const { tokens } = await startLoginFlow({
|
|
62
|
+
config,
|
|
63
|
+
store,
|
|
64
|
+
openBrowser: openInBrowser,
|
|
65
|
+
logger: stderrLogger
|
|
66
|
+
});
|
|
67
|
+
console.error(`Logged in as @${tokens.username ?? "?"} (scopes: ${tokens.scopes.join(", ")}). Refresh token stored in ${config.tokenFile} with mode 600.`);
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
const main = async () => {
|
|
71
|
+
stderrLogger.warn(`${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`);
|
|
72
|
+
const command = process.argv[2];
|
|
73
|
+
if (command && await runSubcommand(command)) return;
|
|
74
|
+
const config = loadConfig();
|
|
75
|
+
const { server } = createServer({
|
|
76
|
+
config,
|
|
77
|
+
logger: stderrLogger
|
|
78
|
+
});
|
|
79
|
+
const transport = new StdioServerTransport();
|
|
80
|
+
await server.connect(transport);
|
|
81
|
+
stderrLogger.warn(`x-api-mcp connected (auth=${describeAuth(config)}, writes=${config.allowWrites ? `ENABLED via ${config.writeBackend}` : "disabled"}, compose=intent (free), full-archive=${config.enableFullArchive ? "on" : "off"}, ads=${describeAds(config)}, cache=${config.cacheEnabled ? "on" : "off"})`);
|
|
82
|
+
if (!hasApiCredentials(config)) {
|
|
83
|
+
for (const line of setupInstructions(config)) stderrLogger.warn(line);
|
|
84
|
+
stderrLogger.warn("Call the x_auth_status tool for this same guidance inside your client.");
|
|
85
|
+
}
|
|
86
|
+
const shutdown = (signal) => {
|
|
87
|
+
stderrLogger.warn(`received ${signal}, shutting down`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
};
|
|
90
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
91
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* A misconfiguration is the most likely first-run failure, and the config
|
|
95
|
+
* schema's messages are written to be read. Dumping a raw ZodError with a stack
|
|
96
|
+
* trace buries them, so unwrap it to just the messages — the stack tells the
|
|
97
|
+
* user nothing they can act on.
|
|
98
|
+
*/
|
|
99
|
+
const describeFatal = (err) => {
|
|
100
|
+
if (err instanceof ZodError) return err.issues.map((issue) => {
|
|
101
|
+
const path = issue.path.join(".");
|
|
102
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
103
|
+
}).join("\n");
|
|
104
|
+
return err instanceof Error ? err.message : String(err);
|
|
105
|
+
};
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
console.error(`[x-api-mcp] ${describeFatal(err)}`);
|
|
108
|
+
if (process.env.X_API_DEBUG && err instanceof Error) console.error(err.stack);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
//#endregion
|
|
112
|
+
export {};
|
|
113
|
+
|
|
114
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from \"@modelcontextprotocol/server/stdio\";\nimport { ZodError } from \"zod\";\n\nimport { BUILD_INFO } from \"#/build-info\";\nimport { startLoginFlow } from \"#/client/oauth\";\nimport { openInBrowser } from \"#/compose/open\";\nimport { hasAdsAccess, hasApiCredentials, loadConfig, setupInstructions } from \"#/config\";\nimport { createServer } from \"#/server\";\n\n// Everything goes to stderr: stdout is the MCP protocol channel, and a stray\n// log line there corrupts the stream.\nconst stderrLogger = {\n debug: (...args: unknown[]) => {\n if (process.env.X_API_DEBUG) console.error(\"[x-api-mcp]\", ...args);\n },\n warn: (...args: unknown[]) => console.error(\"[x-api-mcp]\", ...args),\n error: (...args: unknown[]) => console.error(\"[x-api-mcp]\", ...args),\n};\n\n/**\n * The ads segment of the startup banner. It spells out the environment because\n * \"LIVE+writes\" scrolling past is the last chance anyone has to notice before\n * an agent changes a campaign that spends real money.\n */\nconst describeAds = (config: ReturnType<typeof loadConfig>): string => {\n if (!hasAdsAccess(config)) return \"off\";\n const env = /ads-api-sandbox\\./.test(config.adsBaseUrl) ? \"SANDBOX\" : \"LIVE\";\n return `${env}${config.adsAllowWrites ? \"+writes\" : \" read-only\"}`;\n};\n\nconst describeAuth = (config: ReturnType<typeof loadConfig>): string => {\n const parts: string[] = [];\n if (config.bearerToken) parts.push(\"bearer\");\n if (config.clientId) parts.push(\"oauth2\");\n return parts.length > 0 ? parts.join(\"+\") : \"none\";\n};\n\n/**\n * `login` / `logout` / `status` run and exit; anything else starts the stdio\n * server. Handled before the transport is created, so these never write to the\n * protocol channel.\n */\nconst runSubcommand = async (command: string): Promise<boolean> => {\n if (![\"login\", \"logout\", \"status\"].includes(command)) return false;\n\n const config = loadConfig();\n const { store, tokenProvider } = createServer({ config, logger: stderrLogger });\n\n if (command === \"status\") {\n const status = tokenProvider.describe();\n console.error(\n `ads: ${describeAds(config)}${hasAdsAccess(config) ? ` (${config.adsBaseUrl})` : \"\"}`,\n );\n console.error(`app-only bearer: ${status.app ? \"configured\" : \"not configured\"}`);\n console.error(\n status.user.authenticated\n ? `oauth2 user: @${status.user.username ?? \"?\"} (scopes: ${status.user.scopes.join(\", \")}, ` +\n `expires ${new Date(status.user.expiresAt).toISOString()})`\n : `oauth2 user: not authenticated — ${status.user.reason}`,\n );\n console.error(`token file: ${config.tokenFile}`);\n return true;\n }\n\n if (command === \"logout\") {\n store.clear();\n console.error(`Removed ${config.tokenFile}. The app-only Bearer token is unaffected.`);\n return true;\n }\n\n if (!config.clientId) {\n console.error(\n \"Cannot log in: X_API_CLIENT_ID is not set. Create an OAuth 2.0 app in the X developer \" +\n `portal, enable PKCE, and register this exact callback URL: ${config.redirectUri}`,\n );\n process.exit(1);\n }\n\n const { tokens } = await startLoginFlow({\n config,\n store,\n openBrowser: openInBrowser,\n logger: stderrLogger,\n });\n console.error(\n `Logged in as @${tokens.username ?? \"?\"} (scopes: ${tokens.scopes.join(\", \")}). ` +\n `Refresh token stored in ${config.tokenFile} with mode 600.`,\n );\n return true;\n};\n\nconst main = async (): Promise<void> => {\n stderrLogger.warn(\n `${BUILD_INFO.name}@${BUILD_INFO.version} (git ${BUILD_INFO.gitCommit} ${BUILD_INFO.gitCommitDate}, node ${process.version})`,\n );\n\n const command = process.argv[2];\n if (command && (await runSubcommand(command))) return;\n\n const config = loadConfig();\n const { server } = createServer({ config, logger: stderrLogger });\n const transport = new StdioServerTransport();\n await server.connect(transport);\n\n stderrLogger.warn(\n `x-api-mcp connected (auth=${describeAuth(config)}, ` +\n `writes=${config.allowWrites ? `ENABLED via ${config.writeBackend}` : \"disabled\"}, ` +\n `compose=intent (free), ` +\n `full-archive=${config.enableFullArchive ? \"on\" : \"off\"}, ` +\n `ads=${describeAds(config)}, ` +\n `cache=${config.cacheEnabled ? \"on\" : \"off\"})`,\n );\n\n // Connecting successfully but exposing only four tools is confusing unless we\n // say why. The server no longer refuses to start over this, so the banner and\n // x_auth_status are the only channels left.\n if (!hasApiCredentials(config)) {\n for (const line of setupInstructions(config)) stderrLogger.warn(line);\n stderrLogger.warn(\"Call the x_auth_status tool for this same guidance inside your client.\");\n }\n\n const shutdown = (signal: string): void => {\n stderrLogger.warn(`received ${signal}, shutting down`);\n process.exit(0);\n };\n process.on(\"SIGINT\", () => shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => shutdown(\"SIGTERM\"));\n};\n\n/**\n * A misconfiguration is the most likely first-run failure, and the config\n * schema's messages are written to be read. Dumping a raw ZodError with a stack\n * trace buries them, so unwrap it to just the messages — the stack tells the\n * user nothing they can act on.\n */\nconst describeFatal = (err: unknown): string => {\n if (err instanceof ZodError) {\n return err.issues\n .map((issue) => {\n const path = issue.path.join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n })\n .join(\"\\n\");\n }\n return err instanceof Error ? err.message : String(err);\n};\n\nmain().catch((err: unknown) => {\n console.error(`[x-api-mcp] ${describeFatal(err)}`);\n if (process.env.X_API_DEBUG && err instanceof Error) console.error(err.stack);\n process.exit(1);\n});\n"],"mappings":"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"}
|