@twitterapis/mcp 0.6.1 → 0.6.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 (2026-08-02)
4
+
5
+ ### Added
6
+
7
+ - **Two new tools, `twitter_blocking` and `twitter_muting`**, for the accounts your authenticated account has blocked or muted. Both are cursor-paginated lists of full user objects and both read YOUR OWN lists only: there is no `user_id` argument, because X provides no way to read another account's block or mute list and an argument the API ignores would be worse than none. An empty `users` array means you block or mute nobody; it is never a silent parse failure, because the endpoint returns an error status rather than an empty page when it cannot read the list. The catalog is now **51 tools: 37 reads and 14 write actions**.
8
+ - **Registry descriptors so the server is discoverable outside npm**: `server.json` for the official MCP Registry, `smithery.yaml` with a full `configSchema` (so hosted installers prompt for the API key by name rather than showing a bare variable), and `glama.json` for the maintainer claim. npm is a pull channel; these are where agent users browse.
9
+
10
+ ### Fixed
11
+
12
+ - **`npm test` was failing on `main`, which blocked any release.** `twitter_users_by_ids` and `twitter_media_status` were merged on 2026-07-31 but the catalog-count assertions were left at the pre-merge 47 tools / 33 reads, so the suite reported a mismatch that had nothing to do with the tools themselves. The counts are corrected and now carry a reads-plus-writes-equals-total invariant that does not depend on them, so two cancelling errors cannot pass.
13
+
14
+ ### Changed
15
+
16
+ - Release tooling hardened. No user-facing or API behaviour change.
17
+
18
+ ## Unreleased
19
+
20
+ ### Added
21
+
22
+ - **Seven new tools, closing the gap between the MCP surface and the endpoints the API serves.** Reads: `twitter_trends` (top trends for a location, by `country` or `woeid`), `twitter_trends_locations` (every location X publishes trends for, each with its WOEID), and `twitter_account_me` / `twitter_account_payments` (your twitterapis.com account details and payment history; both free, and served on the un-prefixed `/account/*` path). Session and write: `twitter_customer_session` (register your x.com cookies against your key), `twitter_user_login` (log in with username/password, plus `totp_secret` for 2FA), and `twitter_media_upload` (upload a base64 image, returns a `media_id` for `twitter_create_tweet`). The catalog is now 47 tools: 33 reads and 14 write actions.
23
+ - **A JSON-request-body transport for the three endpoints whose handler reads one.** `twitter_customer_session`, `twitter_user_login`, and `twitter_media_upload` set `jsonBody: true`, so their arguments are sent in the JSON body rather than the query string, matching the routes that read `c.req.json()`. For these tools the credential fields are the body payload and are not diverted into `x-*` headers.
24
+ - `twitter_user_login` documents its REAL response contract, `{ ok, username, message }`. The account cookies it mints are stored server-side against your key and are never returned to the caller. (The published OpenAPI still describes an `{ auth_token, ct0, twid }` response for this endpoint, which the live handler does not send; a code comment on the tool flags the mismatch for maintainers.)
25
+
26
+ ### Fixed
27
+
28
+ - **`twitter_tweet_thread` no longer advertises a `cursor` it ignores.** `/twitter/tweet/thread` returns the whole ordered thread in a single response and accepts only `id`/`url`, so the tool's `cursor` argument and its "paginate with cursor" wording were removed to match the contract (the live `openapi.json` had already dropped `cursor` here).
29
+ - **`twitter_user_about` description refreshed** to cover the fields the endpoint returns today: verification and identity-verification flags, linked website, and X's "About this account" transparency panel (account country, how the account was created, and username-change history).
30
+ - **`test/openapi.snapshot.json` regenerated from the live `openapi.json`**, bringing the vendored offline copy back in sync. It had drifted on 23 endpoints' fields, and now also carries the four new paths and the `Trend` component schemas.
31
+
32
+ ### Changed
33
+
34
+ - Release tooling hardened. No user-facing or API behaviour change.
35
+
3
36
  ## 0.6.1 (2026-07-20)
4
37
 
5
38
  ### Fixed
package/README.md CHANGED
@@ -91,9 +91,9 @@ Restart Claude Desktop. The `twitter_*` tools appear in the tool picker.
91
91
 
92
92
  ## Tools
93
93
 
94
- 40 tools: 29 reads and 11 write actions. Most user endpoints accept `username` (handle without @) **or** `user_id` (`twitter_user_likes` and `twitter_user_tweets_complete` require `user_id`); tweet endpoints accept `id` **or** `url`; paginated endpoints return a `cursor` you pass back to get the next page.
94
+ 47 tools: 33 reads and 14 write actions. Most user endpoints accept `username` (handle without @) **or** `user_id` (`twitter_user_likes` and `twitter_user_tweets_complete` require `user_id`); tweet endpoints accept `id` **or** `url`; paginated endpoints return a `cursor` you pass back to get the next page. Two of the reads are free account/billing lookups (`twitter_account_me`, `twitter_account_payments`).
95
95
 
96
- Public reads (search, profiles, tweets, followers, likes) work with just your API key. The **account-only** reads (bookmarks, DMs, home timeline, followers-you-know) and **all write actions** act AS an authenticated X account, so they need a session linked to your key first (returns HTTP 409 until then). Alternatively, pass **per-call inline credentials** on any of those tools (`auth_token` + `ct0`, with optional `proxy_url` / `user_agent`) to act AS that account for a single call without pre-registering a session, so one API key can act as many accounts. For write actions, set `proxy_url` to a residential proxy, since X soft-blocks writes that egress from datacenter IPs. Each write tool is annotated `readOnlyHint: false`; reversing actions (delete, unfollow, unlike, unretweet, unbookmark) are annotated `destructiveHint: true` so MCP clients can prompt before running them.
96
+ Public reads (search, profiles, tweets, followers, likes) work with just your API key. The **account-only** reads (bookmarks, DMs, home timeline, followers-you-know) and **all write actions** act AS an authenticated X account, so they need a session linked to your key first (returns HTTP 409 until then). Link a session either by registering your x.com cookies (`twitter_customer_session`) or by logging in with a username/password (`twitter_user_login`). Alternatively, pass **per-call inline credentials** on any of those tools (`auth_token` + `ct0`, with optional `proxy_url` / `user_agent`) to act AS that account for a single call without pre-registering a session, so one API key can act as many accounts. For write actions, set `proxy_url` to a residential proxy, since X soft-blocks writes that egress from datacenter IPs. Each write tool is annotated `readOnlyHint: false`; reversing actions (delete, unfollow, unlike, unretweet, unbookmark) are annotated `destructiveHint: true` so MCP clients can prompt before running them.
97
97
 
98
98
  ### Reads
99
99
 
@@ -103,7 +103,7 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
103
103
  | `twitter_user_search` | Find user accounts by name or keyword |
104
104
  | `twitter_user_info` | Full profile by handle (bio, counts, verification, location) |
105
105
  | `twitter_user_info_by_id` | Full profile by numeric user id |
106
- | `twitter_user_about` | A user's structured About panel (category, professional labels, joined date) |
106
+ | `twitter_user_about` | A user's structured About object (category, professional/business labels, verification + identity-verification flags, joined date, and X's 'About this account' transparency panel) |
107
107
  | `twitter_user_affiliates` | Accounts affiliated with an organization profile |
108
108
  | `twitter_check_follow_relationship` | Follow relationship between two user ids (who follows whom) |
109
109
  | `twitter_user_tweets` | A user's recent original tweets (replies excluded) |
@@ -128,6 +128,10 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
128
128
  | `twitter_bookmark_search` | Full-text search within your bookmarks _(session)_ |
129
129
  | `twitter_dm_list` | Your DM conversations (inbox), read-only _(session)_ |
130
130
  | `twitter_dm_conversation` | Messages in one DM conversation, read-only _(session)_ |
131
+ | `twitter_trends` | Current top trends for a location (by `country` or `woeid`) |
132
+ | `twitter_trends_locations` | Every location X has trends for, each with its WOEID |
133
+ | `twitter_account_me` | Your twitterapis.com account: credits, usage, email (free) |
134
+ | `twitter_account_payments` | Your twitterapis.com payment history (free) |
131
135
 
132
136
  ### Write actions _(require a linked X session)_
133
137
 
@@ -140,6 +144,16 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
140
144
  | `twitter_bookmark_tweet` / `twitter_unbookmark_tweet` | Bookmark / remove bookmark |
141
145
  | `twitter_follow_user` / `twitter_unfollow_user` | Follow / unfollow a user by id |
142
146
  | `twitter_dm_send` | Send a Direct Message to a user by their numeric `recipient_id` |
147
+ | `twitter_media_upload` | Upload a base64 image, returns a `media_id` for `twitter_create_tweet` |
148
+
149
+ ### Session setup
150
+
151
+ Link an X account to your key once, so the account-only reads and write actions act as it (or pass per-call `auth_token`/`ct0` instead).
152
+
153
+ | Tool | What it does |
154
+ |---|---|
155
+ | `twitter_customer_session` | Register your x.com session cookies (`auth_token` + `ct0`) against your key |
156
+ | `twitter_user_login` | Log in with `username` + `password` (+ `totp_secret` for 2FA); stores the session against your key. Returns a confirmation, never the cookies |
143
157
 
144
158
  ## Usage examples
145
159
 
@@ -221,7 +235,7 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
221
235
 
222
236
  **Do I need an X (Twitter) developer account?** No. Get an API key at [twitterapis.com/signup](https://www.twitterapis.com/signup); there is no application or approval step.
223
237
 
224
- **Is it read-only?** No. 29 read tools work with just your API key; 11 write actions (post, like, retweet, follow, DM) act as a linked X account or per-call inline credentials.
238
+ **Is it read-only?** No. 33 read tools work with just your API key; 14 write actions (post, like, retweet, follow, DM, media upload) act as a linked X account or per-call inline credentials.
225
239
 
226
240
  **Which clients are supported?** Claude Desktop, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
227
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twitterapis/mcp",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Official MCP server for twitterapis.com, the Twitter/X API (search, users, followers, tweets, threads, lists, likes, bookmarks, DMs) plus write actions (post/like/retweet/follow) as native tools for Claude, Cursor, and any MCP client.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,9 +23,11 @@
23
23
  "scripts": {
24
24
  "start": "node src/index.js",
25
25
  "check": "node --check src/index.js && node --check src/tools.js",
26
- "test": "node test/tools.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/firewall.mjs",
26
+ "test": "node test/tools.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs",
27
+ "prepublishOnly": "npm test",
27
28
  "check:openapi-parity": "node test/openapi-parity.mjs",
28
- "check:firewall": "node test/firewall.mjs"
29
+ "check:firewall": "node test/firewall.mjs",
30
+ "check:registry": "node test/registry-manifests.mjs"
29
31
  },
30
32
  "dependencies": {
31
33
  "@modelcontextprotocol/sdk": "^1.0.0",
package/src/index.js CHANGED
@@ -40,18 +40,13 @@ if (!API_KEY) {
40
40
  }
41
41
 
42
42
  // ── REST call ────────────────────────────────────────────────────────────────
43
- // Every endpoint (GET reads and POST writes alike) reads its params from the
44
- // query string, so the same buildQuery path serves both; only the HTTP method
45
- // differs per tool.
46
- async function callEndpoint(path, args, method = "GET") {
47
- // Pull per-call inline credentials out of args so they travel as request
48
- // headers, never the query string (the API reads x-auth-token / x-ct0; passing
49
- // them as query params would leak them into URLs and access logs). When
50
- // supplied, this one API key acts as that account; otherwise the key's linked
51
- // session is used. Lets a single key act as many accounts.
52
- const { auth_token, ct0, user_agent, proxy_url, ...rest } = args || {};
53
- const q = buildQuery(rest);
54
- const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
43
+ // Most endpoints (GET reads and the simple POST writes alike) read their params
44
+ // from the query string, so the same buildQuery path serves both and only the
45
+ // HTTP method differs. A few POST endpoints (customer/session, user_login,
46
+ // media/upload) instead read a JSON request body; those tools set jsonBody:true
47
+ // and callEndpoint sends the args in the body rather than the query string.
48
+ async function callEndpoint(path, args, method = "GET", jsonBody = false) {
49
+ const all = args || {};
55
50
 
56
51
  const headers = {
57
52
  // The API accepts either header; send both for maximum compatibility.
@@ -60,11 +55,33 @@ async function callEndpoint(path, args, method = "GET") {
60
55
  accept: "application/json",
61
56
  "user-agent": `twitterapis-mcp/${VERSION}`,
62
57
  };
63
- if (auth_token && ct0) {
64
- headers["x-auth-token"] = auth_token;
65
- headers["x-ct0"] = ct0;
66
- if (user_agent) headers["x-user-agent"] = user_agent;
67
- if (proxy_url) headers["x-proxy-url"] = proxy_url;
58
+
59
+ let url;
60
+ let reqBody;
61
+ if (jsonBody) {
62
+ // Endpoints whose handler reads a JSON request body (customer/session,
63
+ // user_login, media/upload). Send every arg in the body: for customer/session
64
+ // and user_login the credentials ARE the payload the handler reads from the
65
+ // body, so they must NOT be diverted into x-* headers the way per-call inline
66
+ // creds are on the query-string tools.
67
+ url = `${BASE_URL}${path}`;
68
+ headers["content-type"] = "application/json";
69
+ reqBody = JSON.stringify(all);
70
+ } else {
71
+ // Pull per-call inline credentials out of args so they travel as request
72
+ // headers, never the query string (the API reads x-auth-token / x-ct0; passing
73
+ // them as query params would leak them into URLs and access logs). When
74
+ // supplied, this one API key acts as that account; otherwise the key's linked
75
+ // session is used. Lets a single key act as many accounts.
76
+ const { auth_token, ct0, user_agent, proxy_url, ...rest } = all;
77
+ const q = buildQuery(rest);
78
+ url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
79
+ if (auth_token && ct0) {
80
+ headers["x-auth-token"] = auth_token;
81
+ headers["x-ct0"] = ct0;
82
+ if (user_agent) headers["x-user-agent"] = user_agent;
83
+ if (proxy_url) headers["x-proxy-url"] = proxy_url;
84
+ }
68
85
  }
69
86
 
70
87
  const ctrl = new AbortController();
@@ -73,6 +90,7 @@ async function callEndpoint(path, args, method = "GET") {
73
90
  const res = await fetch(url, {
74
91
  method,
75
92
  headers,
93
+ body: reqBody,
76
94
  signal: ctrl.signal,
77
95
  });
78
96
  const body = await res.text();
@@ -120,7 +138,7 @@ for (const tool of TOOLS) {
120
138
  server.registerTool(
121
139
  tool.name,
122
140
  { description: tool.description, inputSchema: tool.shape, annotations },
123
- async (args) => callEndpoint(tool.path, args, method),
141
+ async (args) => callEndpoint(tool.path, args, method, Boolean(tool.jsonBody)),
124
142
  );
125
143
  }
126
144
 
package/src/tools.js CHANGED
@@ -111,11 +111,22 @@ export const TOOLS = [
111
111
  ),
112
112
  },
113
113
  },
114
+ {
115
+ name: "twitter_users_by_ids",
116
+ path: "/twitter/users/by_ids",
117
+ description:
118
+ "Resolve up to 100 numeric user ids into full profiles in ONE call. Same user object as twitter_user_info_by_id, returned as a list. Use this whenever you hold several ids and would otherwise loop twitter_user_info_by_id, for example hydrating the authors of a batch of tweets. Ids that no longer resolve (suspended or deleted accounts) are omitted rather than returned as nulls; compare the requested and resolved counts in the response, or diff the returned ids against the ones you sent, to see which were dropped. Sending more than 100 ids is rejected rather than truncated, so a short list always means those accounts are gone, never that the request was clipped.",
119
+ shape: {
120
+ user_ids: z.string().describe(
121
+ "Comma-separated numeric Twitter/X user ids, up to 100 (e.g. '44196397,745273'). Duplicates are collapsed and billed once.",
122
+ ),
123
+ },
124
+ },
114
125
  {
115
126
  name: "twitter_user_about",
116
127
  path: "/twitter/user/user_about",
117
128
  description:
118
- "Get a user's 'About' panel: the structured profile facts X surfaces beyond the bio, such as account category, professional/business labels, joined date, and location when present. Provide a username or a user_id. Use this to enrich a profile beyond what twitter_user_info returns.",
129
+ "Get a user's full 'About' object: the structured profile facts X surfaces beyond the bio, including account category and professional/business labels, verification and identity-verification flags, joined date, location and linked website, follower/following counts, and X's 'About this account' transparency panel (the account's country, how the account was created, and its username-change history). Provide a username or a user_id. Use this to enrich a profile beyond what twitter_user_info returns.",
119
130
  shape: { ...USER_REF },
120
131
  },
121
132
  {
@@ -273,8 +284,12 @@ export const TOOLS = [
273
284
  name: "twitter_tweet_thread",
274
285
  path: "/twitter/tweet/thread",
275
286
  description:
276
- "Get all tweets in a thread: the connected chain of tweets posted by the SAME author in sequence (a tweetstorm or numbered thread). Pass any tweet id/url from the thread and the API returns the full ordered sequence. Paginate with cursor for long threads. Does NOT return replies from other users, use twitter_tweet_replies for that.",
277
- shape: { ...TWEET_REF, ...CURSOR },
287
+ "Get all tweets in a thread: the connected chain of tweets posted by the SAME author in sequence (a tweetstorm or numbered thread). Pass any tweet id/url from the thread and the API returns the full ordered sequence in a single call. Does NOT return replies from other users, use twitter_tweet_replies for that. Accepts either the tweet id or its full URL.",
288
+ // No cursor: /twitter/tweet/thread returns the whole ordered thread in one
289
+ // response and takes no pagination param (openapi lists only id/url). The
290
+ // previous ...CURSOR advertised a cursor the endpoint ignores and drove a
291
+ // false "paginate with cursor" claim; removed to match the real contract.
292
+ shape: { ...TWEET_REF },
278
293
  },
279
294
  {
280
295
  name: "twitter_tweet_retweeters",
@@ -295,6 +310,46 @@ export const TOOLS = [
295
310
  ...PAGINATION,
296
311
  },
297
312
  },
313
+ // ── Reads: trends ──────────────────────────────────────────────────────────
314
+ {
315
+ name: "twitter_trends",
316
+ path: "/twitter/trends",
317
+ description:
318
+ "Get the current top trends for a location. With no location parameter, returns Worldwide (WOEID 1, X's own default). Pass country (an ISO code or country name, e.g. 'US' or 'Japan') or a numeric woeid from twitter_trends_locations; woeid wins when both are given. Returns the resolved location, the as_of / created_at timestamps, and the ranked trends list. Use count to truncate the list. A location X will not serve returns a 400.",
319
+ shape: {
320
+ country: z.string().optional().describe(
321
+ "Country name or ISO code to get trends for, e.g. 'US' or 'Japan'. Resolved against the trends locations list. Omit for Worldwide.",
322
+ ),
323
+ woeid: z.string().optional().describe(
324
+ "Numeric WOEID from twitter_trends_locations. Takes precedence over country when both are supplied.",
325
+ ),
326
+ count: z.number().int().min(1).optional().describe(
327
+ "Truncate the returned trends list to at most this many. Omit to return X's full list for the location.",
328
+ ),
329
+ },
330
+ },
331
+ {
332
+ name: "twitter_trends_locations",
333
+ path: "/twitter/trends/locations",
334
+ description:
335
+ "List every location X publishes trends for, each with the numeric WOEID to pass back to twitter_trends as woeid. Takes no parameters. Use this to resolve a country or city to its WOEID before requesting trends for that place.",
336
+ shape: {},
337
+ },
338
+ // ── Reads: your twitterapis.com account (billing; not Twitter data) ─────────
339
+ {
340
+ name: "twitter_account_me",
341
+ path: "/account/me",
342
+ description:
343
+ "Get YOUR twitterapis.com account details: email, name, credits remaining, credits used, total requests made, and account creation date. Authenticated by your API key. This is an account read, not Twitter data, and is free (it does not spend credits).",
344
+ shape: {},
345
+ },
346
+ {
347
+ name: "twitter_account_payments",
348
+ path: "/account/payments",
349
+ description:
350
+ "Get YOUR twitterapis.com payment history: the list of top-ups and charges on your account. Authenticated by your API key. This is an account read, not Twitter data, and is free (it does not spend credits).",
351
+ shape: {},
352
+ },
298
353
  // ── Reads: authenticated-account surfaces (require a session behind your key) ─
299
354
  {
300
355
  name: "twitter_home_timeline",
@@ -310,6 +365,20 @@ export const TOOLS = [
310
365
  "List YOUR authenticated account's bookmarked tweets, most recent first. Requires an authenticated session behind your key. Returns each bookmarked tweet with author and metrics plus a cursor.",
311
366
  shape: { ...PAGINATION, ...INLINE },
312
367
  },
368
+ {
369
+ name: "twitter_blocking",
370
+ path: "/twitter/user/blocking",
371
+ description:
372
+ "List the accounts YOUR authenticated account has BLOCKED, as full user objects, cursor-paginated. Requires an authenticated session behind your key. There is no user_id argument: X provides no way to read another account's block list, so this reads yours only. An empty users array is a real answer meaning you block nobody, never a silent failure, because the endpoint returns an error status rather than an empty page when it cannot read the list.",
373
+ shape: { ...PAGINATION, ...INLINE },
374
+ },
375
+ {
376
+ name: "twitter_muting",
377
+ path: "/twitter/user/muting",
378
+ description:
379
+ "List the accounts YOUR authenticated account has MUTED, as full user objects, cursor-paginated. Muting hides an account's posts from your timeline without blocking it, so this is a different list from twitter_blocking and an account can appear in one and not the other. Requires an authenticated session behind your key. There is no user_id argument: X provides no way to read another account's mute list. An empty users array means you mute nobody, never a silent failure.",
380
+ shape: { ...PAGINATION, ...INLINE },
381
+ },
313
382
  {
314
383
  name: "twitter_bookmark_search",
315
384
  path: "/twitter/user/bookmark_search",
@@ -482,6 +551,88 @@ export const TOOLS = [
482
551
  ...INLINE,
483
552
  },
484
553
  },
554
+ // ── Session bootstrap + media: link an X account to your key, then act as it ─
555
+ // Once a session is linked (via twitter_customer_session or twitter_user_login)
556
+ // the authenticated-account reads and the write actions run AS that account.
557
+ // These three send a JSON request body (jsonBody:true), so the fields travel
558
+ // in the body, not the query string, matching the backend routes that read
559
+ // c.req.json().
560
+ {
561
+ name: "twitter_customer_session",
562
+ path: "/twitter/customer/session",
563
+ method: "POST",
564
+ write: true,
565
+ jsonBody: true,
566
+ description:
567
+ "Register YOUR OWN X account session against your API key, so the authenticated-account tools (twitter_home_timeline, twitter_bookmarks, twitter_dm_list, twitter_dm_conversation, twitter_user_likes) and the write tools (twitter_create_tweet, twitter_dm_send, twitter_follow_user, twitter_favorite_tweet, twitter_retweet, twitter_media_upload) act as your account. Provide your x.com session cookies auth_token and ct0 (copy them from a logged-in browser); optionally a user_agent and a residential proxy_url. The cookies are stored server-side against your key and are never returned. Returns ok, the resolved username, and whether the session validated live. Prefer twitter_user_login if you would rather pass a username/password than raw cookies. Most tools also accept auth_token/ct0 per-call without registering.",
568
+ shape: {
569
+ auth_token: z.string().describe(
570
+ "Your x.com auth_token cookie value, from a logged-in browser session. Stored server-side against your key; never returned.",
571
+ ),
572
+ ct0: z.string().describe(
573
+ "Your x.com ct0 (CSRF) cookie value, from the same browser session. Paired with auth_token.",
574
+ ),
575
+ user_agent: z.string().optional().describe(
576
+ "Optional. Browser User-Agent to send with this session's requests. Defaults to a current Chrome UA.",
577
+ ),
578
+ proxy_url: z.string().optional().describe(
579
+ "Optional. HTTP or SOCKS proxy URL to route this session's traffic through, e.g. 'http://user:pass@host:port'.",
580
+ ),
581
+ },
582
+ },
583
+ {
584
+ name: "twitter_user_login",
585
+ path: "/twitter/user/user_login",
586
+ method: "POST",
587
+ write: true,
588
+ jsonBody: true,
589
+ // CONTRACT NOTE (maintainers): the published openapi documents an
590
+ // {auth_token, ct0, twid} response for this endpoint. That is WRONG. The
591
+ // live handler (backend routes/user-login.ts) returns {ok, username,
592
+ // message} and stores the minted session server-side; it never returns the
593
+ // cookies. The description below documents the REAL contract, not the
594
+ // openapi's. Fixing the openapi response schema is a docs/website change.
595
+ description:
596
+ "Log in to X with a username and password (plus totp_secret if the account has 2FA) and store the resulting session against your API key, so the authenticated-account reads and the write tools then act as that account. On success returns { ok, username, message }; it does NOT return the session cookies (auth_token/ct0 are minted and kept server-side, never sent back). Typical failures: bad_credentials (401), two_factor_required (400, add totp_secret), captcha_required (422), acid_challenge (409, confirm the login from the account then retry). This handles real account credentials; never log or echo the values you pass.",
597
+ shape: {
598
+ username: z.string().describe(
599
+ "The X account username/handle (without the leading @). Some accounts also accept the login email here.",
600
+ ),
601
+ password: z.string().describe(
602
+ "The X account password.",
603
+ ),
604
+ totp_secret: z.string().optional().describe(
605
+ "The account's base32 two-factor (TOTP) secret. Required only when the account has 2FA enabled.",
606
+ ),
607
+ },
608
+ },
609
+ {
610
+ name: "twitter_media_upload",
611
+ path: "/twitter/media/upload",
612
+ method: "POST",
613
+ write: true,
614
+ jsonBody: true,
615
+ description:
616
+ "Upload an image to X and get a media_id to attach to a tweet via twitter_create_tweet's media_ids. Provide media_data as base64-encoded image bytes. Acts as your registered account session (register first with twitter_customer_session or twitter_user_login, or pass auth_token/ct0 for this call). Returns ok and the media_id. Only base64 image data is supported over this tool's JSON transport.",
617
+ shape: {
618
+ media_data: z.string().describe(
619
+ "Base64-encoded image bytes to upload. Sent in the JSON request body.",
620
+ ),
621
+ ...INLINE,
622
+ },
623
+ },
624
+ {
625
+ name: "twitter_media_status",
626
+ path: "/twitter/media/status",
627
+ description:
628
+ "Check whether an uploaded media_id has finished processing on X, before you attach it to a tweet. Video, GIF and large uploads are processed ASYNCHRONOUSLY: twitter_media_upload returns a media_id immediately, but attaching it via twitter_create_tweet FAILS until X reports state 'succeeded'. Poll this until then. Returns media_id, state ('pending', 'in_progress', 'succeeded' or 'failed'), check_after_secs (how long X asks you to wait before polling again, honour it rather than tight-looping), progress_percent, and an error object when state is 'failed'. Reads through YOUR OWN registered account session, the same one that performed the upload, so register first with twitter_customer_session or twitter_user_login, or pass auth_token/ct0 for this call. This is a READ: no daily write cap applies.",
629
+ shape: {
630
+ media_id: z.string().describe(
631
+ "Numeric media id returned by twitter_media_upload, e.g. '1234567890123456789'.",
632
+ ),
633
+ ...INLINE,
634
+ },
635
+ },
485
636
  ];
486
637
 
487
638
  // Pure query-string builder: drops undefined/null/empty values, URL-encodes the rest.