@twitterapis/mcp 0.6.2 → 0.6.4

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4 (2026-08-02)
4
+
5
+ ### Added
6
+
7
+ - **`mcpName` in `package.json`, which is what the official MCP Registry checks to prove we own this npm package.** It must equal the `name` in `server.json`, and the registry reads it from the tarball on npm rather than from the repo, so 0.6.3 could not be published no matter what the repo said. This is the entire reason 0.6.4 exists as a release; there is no behaviour change and no tool change.
8
+ - The README now carries a Directory listings table recording, per directory, whether the server is actually listed and what that directory requires today. A descriptor file in the repo is not a listing, and the two had drifted apart.
9
+
10
+ ### Fixed
11
+
12
+ - **The 0.6.3 registry descriptors were schema-invalid and would never have listed.** The official registry caps `description` at 100 characters; ours was 216, and the publish endpoint rejected it with HTTP 422. `test/registry-manifests.mjs` reported all three descriptors clean throughout, because it only checked that `description` was a non-empty string. Running the old gate against the 0.6.3 commit still passes while the registry still rejects that same file, which is the clearest statement of what was wrong with it. The gate now pins `description` and `title` at 100 and `name` at 200, the values read off the live schema, and asserts `package.json` `mcpName` equals `server.json` `name`. All four checks were red-tested against mutations before this shipped.
13
+ - **`smithery.yaml` was documented as the thing that gets us onto Smithery, and it is not.** Smithery retired the repo-linked build path, and the filename now appears nowhere in their documentation index. The file is kept, since a few third-party crawlers still read the old convention and it costs nothing, but its header no longer claims to be a submission. Smithery listing needs an account and either a hosted Streamable HTTP endpoint or an MCPB bundle.
14
+
15
+ ### Changed
16
+
17
+ - **`src/tools.js` is now generated at build time instead of maintained by hand.** The catalog is built from two committed inputs: `test/openapi.snapshot.json`, a vendored copy of the published OpenAPI spec, which supplies the structure (which endpoints exist, which parameters each takes, whether a parameter is required, its type); and a new hand-authored `scripts/tools.overrides.mjs`, which supplies everything the spec cannot express, namely the tool and argument descriptions a model reads to decide how to call a tool, the cross-field rules such as "provide exactly one of `username` or `user_id`", the per-call credential arguments that travel as `x-*` request headers and therefore appear in no spec, and the write / destructive / JSON-body flags. Generating the descriptions from the spec instead would have replaced tuned prose (about 307 characters per tool, with routing between sibling tools) with endpoint documentation written for a human reading the docs site (29 to 64 characters, no routing), which is a downgrade to the only text an agent actually reads. **All 51 tools are byte-identical to 0.6.3** across names, REST paths, HTTP methods, flags, argument names and ordering, required-ness, types, bounds, enum members, and every description; `test/catalog-identity.mjs` pins that against a frozen fingerprint of the 0.6.3 catalog and fails on any difference. No behaviour change for any client.
18
+ - The spec is **vendored, not fetched**. Nothing is downloaded at install time or at server boot, so the published package stays a fixed, reviewable artifact rather than one whose tool surface depends on a hostname still answering. `npm run openapi:refresh` re-vendors it as a deliberate, reviewed step and prints the route diff; `npm run build` regenerates the catalog; `npm test` regenerates it in memory and fails if the committed file was hand-edited or left stale.
19
+ - The query-string builder moved to `src/query.js` and is re-exported from `src/tools.js`, so the generated file contains catalog data and no logic. Import paths are unchanged.
20
+
21
+ ### Fixed
22
+
23
+ - **The vendored spec was four endpoints behind the API**, missing `/users/by_ids`, `/user/blocking`, `/user/muting`, and `/media/status`, the four tools added after the snapshot was last refreshed. The parity check only noticed because it prefers the live spec over the vendored copy, so on any run without network access it reported four tools pointing at endpoints that "do not exist" and exited non-zero. Re-vendored; the offline path now passes.
24
+ - **The README was missing four of the 51 tools** (`twitter_users_by_ids`, `twitter_blocking`, `twitter_muting`, `twitter_media_status`) and still said "47 tools: 33 reads". Since the README ships inside the package and is its page on npm, those four were callable but documented nowhere. Rows added, counts corrected, and a new `test/readme-parity.mjs` compares the README against the catalog itself, so a tool can no longer ship without a row or a correct count.
25
+ - The changelog section describing the seven tools added in 0.6.2 was still headed "Unreleased" twelve days after it shipped. Retitled.
26
+
27
+ ## 0.6.3 (2026-08-02)
28
+
29
+ ### Added
30
+
31
+ - **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**.
32
+ - **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.
33
+
34
+ ### Fixed
35
+
36
+ - **`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.
37
+
38
+ ### Changed
39
+
40
+ - Release tooling hardened. No user-facing or API behaviour change.
41
+
3
42
  ## Unreleased
4
43
 
5
44
  ### Added
@@ -16,10 +55,7 @@
16
55
 
17
56
  ### Changed
18
57
 
19
- - The publish firewall now runs on `npm publish` itself, via `prepublishOnly`, not only on a manual `npm test`. A release that skips the test step can no longer reach the registry unchecked. Verified: with a competitor reference reintroduced into the README, `npm publish` aborts before the tarball stage.
20
- - The firewall no longer carries its own list of banned terms. It delegates to the maintainer's isolation registry, which is the single place those rules live, so the gate and everything else that enforces them cannot drift apart. If the registry cannot be located, the gate fails rather than passing.
21
-
22
- The two firewall changes above are release tooling only.
58
+ - Release tooling hardened. No user-facing or API behaviour change.
23
59
 
24
60
  ## 0.6.1 (2026-07-20)
25
61
 
package/README.md CHANGED
@@ -91,7 +91,7 @@ Restart Claude Desktop. The `twitter_*` tools appear in the tool picker.
91
91
 
92
92
  ## Tools
93
93
 
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`).
94
+ 51 tools: 37 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
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
 
@@ -103,6 +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_users_by_ids` | Up to 100 numeric user ids resolved to full profiles in one call |
106
107
  | `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
108
  | `twitter_user_affiliates` | Accounts affiliated with an organization profile |
108
109
  | `twitter_check_follow_relationship` | Follow relationship between two user ids (who follows whom) |
@@ -125,6 +126,8 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
125
126
  | `twitter_list_members` | Members of a Twitter/X List |
126
127
  | `twitter_home_timeline` | Your authenticated account's Home timeline _(session)_ |
127
128
  | `twitter_bookmarks` | Your authenticated account's bookmarks _(session)_ |
129
+ | `twitter_blocking` | Accounts your authenticated account has blocked (your own list only) _(session)_ |
130
+ | `twitter_muting` | Accounts your authenticated account has muted (your own list only) _(session)_ |
128
131
  | `twitter_bookmark_search` | Full-text search within your bookmarks _(session)_ |
129
132
  | `twitter_dm_list` | Your DM conversations (inbox), read-only _(session)_ |
130
133
  | `twitter_dm_conversation` | Messages in one DM conversation, read-only _(session)_ |
@@ -132,6 +135,7 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
132
135
  | `twitter_trends_locations` | Every location X has trends for, each with its WOEID |
133
136
  | `twitter_account_me` | Your twitterapis.com account: credits, usage, email (free) |
134
137
  | `twitter_account_payments` | Your twitterapis.com payment history (free) |
138
+ | `twitter_media_status` | Processing state of an uploaded `media_id`; poll until `succeeded` before attaching video or GIF _(session)_ |
135
139
 
136
140
  ### Write actions _(require a linked X session)_
137
141
 
@@ -235,7 +239,7 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
235
239
 
236
240
  **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.
237
241
 
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.
242
+ **Is it read-only?** No. 37 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.
239
243
 
240
244
  **Which clients are supported?** Claude Desktop, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
241
245
 
@@ -243,6 +247,35 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
243
247
 
244
248
  **Does it store my key or data?** No. The server holds no state and forwards your API key on each call.
245
249
 
250
+ ## Maintainers
251
+
252
+ `src/tools.js` is **generated**. Do not edit it. The catalog is built at build time from two committed inputs:
253
+
254
+ - `test/openapi.snapshot.json`, a vendored copy of the published OpenAPI spec, which supplies the structure: which endpoints exist, which parameters each accepts, whether a parameter is required, and its type.
255
+ - `scripts/tools.overrides.mjs`, hand-authored, which supplies everything the spec cannot express: the tool and argument descriptions a model reads to decide how to call a tool, the cross-field rules ("provide exactly one of `username` or `user_id`"), the per-call credential arguments that travel as `x-*` headers, and the write / destructive / JSON-body flags.
256
+
257
+ The spec is vendored on purpose. Nothing is fetched at install time or at server boot, so the published package is a fixed artifact rather than one that depends on a hostname still answering.
258
+
259
+ ```bash
260
+ npm run openapi:refresh # re-vendor the spec, prints the route diff
261
+ npm run build # regenerate src/tools.js
262
+ npm test # gates, incl. "src/tools.js matches the generator"
263
+ ```
264
+
265
+ `npm test` fails if `src/tools.js` was hand-edited or left stale, if the catalog and the live spec disagree, or if the tool list and this README disagree.
266
+
267
+ ### Directory listings
268
+
269
+ Where this server actually appears, and what each directory needs. A descriptor file sitting in the repo is not a listing, so this table records the listing, not the file. Checked 2026-08-02.
270
+
271
+ | Directory | State | What it takes |
272
+ | --- | --- | --- |
273
+ | Official MCP Registry | listed as `io.github.TwitterAPIs/twitterapis-mcp` | `server.json` plus `mcpName` in the **published** `package.json`. Publish with `mcp-publisher`, authenticating with a GitHub token whose account is an org **admin**. Every release needs a fresh `publish`, since the registry pins a version. |
274
+ | Glama | listed, crawled automatically | Nothing to submit. Glama indexed the GitHub repo on its own. `glama.json` names the maintainer for the claim, but the claim itself is completed from a signed-in Glama account. |
275
+ | Smithery | not listed | `smithery.yaml` no longer does anything: the repo-linked build path was retired and the filename appears nowhere in Smithery's current docs. Listing now means publishing either a public Streamable HTTP endpoint or a prebuilt MCPB bundle, both from a Smithery account with an API key. |
276
+
277
+ Two traps worth keeping in mind. The registry enforces `description` at 100 characters and `title` at 100; `test/registry-manifests.mjs` pins both, because the descriptors passed an earlier version of that gate while the registry rejected them with HTTP 422. And `mcpName` is verified against the tarball on npm, not against the working tree, so a wrong value is only visible after the release has shipped and costs another version to correct.
278
+
246
279
  ## License
247
280
 
248
281
  MIT
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@twitterapis/mcp",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
+ "mcpName": "io.github.TwitterAPIs/twitterapis-mcp",
4
5
  "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
6
  "repository": {
6
7
  "type": "git",
@@ -22,11 +23,15 @@
22
23
  },
23
24
  "scripts": {
24
25
  "start": "node src/index.js",
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
+ "check": "node --check src/index.js && node --check src/tools.js && node --check src/query.js",
27
+ "build": "node scripts/gen-tools.mjs --write",
28
+ "build:check": "node scripts/gen-tools.mjs --check",
29
+ "openapi:refresh": "node scripts/openapi-refresh.mjs",
30
+ "test": "node scripts/gen-tools.mjs --check && node test/catalog-identity.mjs && node test/tools.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs",
27
31
  "prepublishOnly": "npm test",
28
32
  "check:openapi-parity": "node test/openapi-parity.mjs",
29
- "check:firewall": "node test/firewall.mjs"
33
+ "check:firewall": "node test/firewall.mjs",
34
+ "check:registry": "node test/registry-manifests.mjs"
30
35
  },
31
36
  "dependencies": {
32
37
  "@modelcontextprotocol/sdk": "^1.0.0",
package/src/query.js ADDED
@@ -0,0 +1,16 @@
1
+ // Query-string builder for @twitterapis/mcp.
2
+ //
3
+ // Hand-written logic, deliberately kept out of src/tools.js: that file is
4
+ // generated from the vendored openapi spec plus scripts/tools.overrides.mjs, and
5
+ // a generated file should contain no behaviour a reviewer has to read. It is
6
+ // re-exported from src/tools.js so callers keep a single import path.
7
+
8
+ // Drops undefined/null/empty values, URL-encodes the rest. A dumb stringifier,
9
+ // not a validator: Zod has already validated by the time args reach here.
10
+ export function buildQuery(args) {
11
+ const qs = new URLSearchParams();
12
+ for (const [k, v] of Object.entries(args || {})) {
13
+ if (v !== undefined && v !== null && String(v).length > 0) qs.set(k, String(v));
14
+ }
15
+ return qs.toString();
16
+ }
package/src/tools.js CHANGED
@@ -1,67 +1,28 @@
1
- // Tool catalog + pure query-builder for @twitterapis/mcp.
2
- // Kept separate from the server wiring (index.js) so it can be unit-tested
3
- // without spawning the stdio transport.
1
+ // GENERATED FILE. DO NOT EDIT BY HAND.
4
2
  //
5
- // Each tool maps 1:1 to a REST endpoint at https://api.twitterapis.com. Tool
6
- // arg names map 1:1 to endpoint query params (every endpoint, including the
7
- // POST write actions, reads its params from the query string). A tool with
3
+ // Built by scripts/gen-tools.mjs from:
4
+ // test/openapi.snapshot.json the vendored REST contract (structure)
5
+ // scripts/tools.overrides.mjs the hand-authored agent-facing layer (prose)
6
+ //
7
+ // Edit one of those two, then run `npm run build`. `npm test` regenerates this
8
+ // file in memory and fails if it does not match what is committed, so a hand edit
9
+ // here is caught rather than shipped.
10
+ //
11
+ // Catalog: 51 tools (37 reads, 14 writes).
12
+ //
13
+ // Each tool maps 1:1 to a REST endpoint at https://api.twitterapis.com. Tool arg
14
+ // names map 1:1 to endpoint query params (every endpoint, including the POST
15
+ // write actions, reads its params from the query string), except the per-call
16
+ // inline credentials, which travel as x-* request headers, and the three
17
+ // jsonBody tools, whose fields travel in a JSON request body. A tool with
8
18
  // `method: "POST"` is a write that acts on behalf of the authenticated account
9
19
  // behind your API key; reads are GET and default when `method` is omitted.
20
+ //
21
+ // write:true -> action mutates account/Twitter state (readOnlyHint:false)
22
+ // destructive:true -> action removes/reverses state (delete, un-follow/like/RT/bookmark)
10
23
  import { z } from "zod";
11
24
 
12
- // ── Shared Zod input-schema fragments ───────────────────────────────────────
13
- const CURSOR = {
14
- cursor: z.string().optional().describe(
15
- "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
16
- ),
17
- };
18
- const PAGINATION = {
19
- count: z.number().int().min(1).max(200).optional().describe(
20
- "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
21
- ),
22
- ...CURSOR,
23
- };
24
- const USER_REF = {
25
- username: z.string().optional().describe(
26
- 'Twitter/X handle WITHOUT the leading @ (e.g. "elonmusk", "openai"). Provide exactly one of username or user_id.',
27
- ),
28
- user_id: z.string().optional().describe(
29
- 'Numeric Twitter/X user id (e.g. "44196397"). Provide exactly one of username or user_id.',
30
- ),
31
- };
32
- const TWEET_REF = {
33
- id: z.string().optional().describe(
34
- 'Tweet/post numeric id (e.g. "1789012345678901234"). Provide exactly one of id or url.',
35
- ),
36
- url: z.string().optional().describe(
37
- 'Full tweet URL, e.g. "https://x.com/elonmusk/status/1789012345678901234". Provide exactly one of id or url.',
38
- ),
39
- };
40
- // Per-call inline credentials. Pass an account's own X session cookies to act AS
41
- // that account for this one call, without pre-registering a session, so a single
42
- // API key can act as many accounts (e.g. polling several inboxes or posting from
43
- // a pool). Sent as request headers, never in the URL. Omit to use the key's
44
- // linked session.
45
- const INLINE = {
46
- auth_token: z.string().optional().describe(
47
- "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
48
- ),
49
- ct0: z.string().optional().describe(
50
- "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
51
- ),
52
- proxy_url: z.string().optional().describe(
53
- "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
54
- ),
55
- user_agent: z.string().optional().describe(
56
- "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
57
- ),
58
- };
59
-
60
- // ── Tool catalog. Reads are GET (default); writes set method:"POST". ─────────
61
- // write:true -> action mutates account/Twitter state (annotated readOnlyHint:false)
62
- // destructive:true -> action removes/reverses state (delete, un-follow/like/RT/bookmark)
63
25
  export const TOOLS = [
64
- // ── Reads: search + discovery ──────────────────────────────────────────────
65
26
  {
66
27
  name: "twitter_advanced_search",
67
28
  path: "/twitter/tweet/advanced_search",
@@ -71,10 +32,15 @@ export const TOOLS = [
71
32
  query: z.string().describe(
72
33
  "Full advanced-search query string. Supports X operators: from:handle, to:handle, since:YYYY-MM-DD, until:YYYY-MM-DD, min_faves:N, min_retweets:N, filter:links, filter:images, filter:videos, -filter:replies, lang:en, #hashtag, \"exact phrase\". Example: 'from:openai min_faves:500 since:2024-01-01'.",
73
34
  ),
74
- product: z.enum(["Top", "Latest", "Media", "People"]).optional().describe(
35
+ product: z.enum(["Top","Latest","Media","People"]).optional().describe(
75
36
  "Result ranking mode. 'Latest' = reverse-chronological (best for monitoring). 'Top' = engagement-ranked (best for finding popular tweets, default when omitted). 'Media' = tweets with images/video. 'People' = matching user accounts.",
76
37
  ),
77
- ...PAGINATION,
38
+ count: z.number().int().min(1).max(200).optional().describe(
39
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
40
+ ),
41
+ cursor: z.string().optional().describe(
42
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
43
+ ),
78
44
  },
79
45
  },
80
46
  {
@@ -86,7 +52,12 @@ export const TOOLS = [
86
52
  query: z.string().describe(
87
53
  "Name, keyword, or topic to search accounts for. Examples: 'OpenAI', 'AI researcher', 'tech founder'.",
88
54
  ),
89
- ...PAGINATION,
55
+ count: z.number().int().min(1).max(200).optional().describe(
56
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
57
+ ),
58
+ cursor: z.string().optional().describe(
59
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
60
+ ),
90
61
  },
91
62
  },
92
63
  {
@@ -111,12 +82,30 @@ export const TOOLS = [
111
82
  ),
112
83
  },
113
84
  },
85
+ {
86
+ name: "twitter_users_by_ids",
87
+ path: "/twitter/users/by_ids",
88
+ description:
89
+ "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.",
90
+ shape: {
91
+ user_ids: z.string().describe(
92
+ "Comma-separated numeric Twitter/X user ids, up to 100 (e.g. '44196397,745273'). Duplicates are collapsed and billed once.",
93
+ ),
94
+ },
95
+ },
114
96
  {
115
97
  name: "twitter_user_about",
116
98
  path: "/twitter/user/user_about",
117
99
  description:
118
100
  "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
- shape: { ...USER_REF },
101
+ shape: {
102
+ username: z.string().optional().describe(
103
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
104
+ ),
105
+ user_id: z.string().optional().describe(
106
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
107
+ ),
108
+ },
120
109
  },
121
110
  {
122
111
  name: "twitter_user_affiliates",
@@ -124,11 +113,21 @@ export const TOOLS = [
124
113
  description:
125
114
  "List the affiliated accounts of an organization profile (the smaller accounts X displays under a company's 'Affiliated' badge, e.g. employees or sub-brands). Provide a username or user_id. Returns profile data per affiliate plus a pagination cursor. Returns empty for accounts with no affiliations.",
126
115
  shape: {
127
- ...USER_REF,
116
+ username: z.string().optional().describe(
117
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
118
+ ),
119
+ user_id: z.string().optional().describe(
120
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
121
+ ),
128
122
  team: z.string().optional().describe(
129
123
  "Optional team/sub-group name to filter affiliates by, when the org exposes named teams.",
130
124
  ),
131
- ...PAGINATION,
125
+ count: z.number().int().min(1).max(200).optional().describe(
126
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
127
+ ),
128
+ cursor: z.string().optional().describe(
129
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
130
+ ),
132
131
  },
133
132
  },
134
133
  {
@@ -145,20 +144,45 @@ export const TOOLS = [
145
144
  ),
146
145
  },
147
146
  },
148
- // ── Reads: a user's tweets / timeline ──────────────────────────────────────
149
147
  {
150
148
  name: "twitter_user_tweets",
151
149
  path: "/twitter/user/tweets",
152
150
  description:
153
151
  "Get a user's recent original tweets, excluding replies and retweets. Returns tweet text, id, timestamp, and engagement metrics. Paginate with cursor to go further back. Use this to analyse a user's own content, opinions, or posting cadence. For replies too, use twitter_user_tweets_and_replies; for the full back-catalogue in one call, use twitter_user_tweets_complete.",
154
- shape: { ...USER_REF, ...PAGINATION },
152
+ shape: {
153
+ username: z.string().optional().describe(
154
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
155
+ ),
156
+ user_id: z.string().optional().describe(
157
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
158
+ ),
159
+ count: z.number().int().min(1).max(200).optional().describe(
160
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
161
+ ),
162
+ cursor: z.string().optional().describe(
163
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
164
+ ),
165
+ },
155
166
  },
156
167
  {
157
168
  name: "twitter_user_tweets_and_replies",
158
169
  path: "/twitter/user/tweets_and_replies",
159
170
  description:
160
171
  "Get a user's full activity timeline: their original tweets AND replies to others. Useful for understanding how someone engages with a community, not just what they post. Paginate with cursor. To see only original tweets, use twitter_user_tweets.",
161
- shape: { ...USER_REF, ...PAGINATION },
172
+ shape: {
173
+ username: z.string().optional().describe(
174
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
175
+ ),
176
+ user_id: z.string().optional().describe(
177
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
178
+ ),
179
+ count: z.number().int().min(1).max(200).optional().describe(
180
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
181
+ ),
182
+ cursor: z.string().optional().describe(
183
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
184
+ ),
185
+ },
162
186
  },
163
187
  {
164
188
  name: "twitter_user_tweets_complete",
@@ -179,7 +203,20 @@ export const TOOLS = [
179
203
  path: "/twitter/user/media",
180
204
  description:
181
205
  "Get the images and videos a user has posted. Returns media-containing tweets with URLs to the media files, dimensions, and type (photo/video/animated_gif). Paginate with cursor. Use this to pull a user's visual content history.",
182
- shape: { ...USER_REF, ...PAGINATION },
206
+ shape: {
207
+ username: z.string().optional().describe(
208
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
209
+ ),
210
+ user_id: z.string().optional().describe(
211
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
212
+ ),
213
+ count: z.number().int().min(1).max(200).optional().describe(
214
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
215
+ ),
216
+ cursor: z.string().optional().describe(
217
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
218
+ ),
219
+ },
183
220
  },
184
221
  {
185
222
  name: "twitter_user_mentions",
@@ -190,7 +227,12 @@ export const TOOLS = [
190
227
  username: z.string().describe(
191
228
  "Twitter/X handle WITHOUT the leading @ of the user to find mentions for (e.g. 'openai' to find tweets mentioning @openai).",
192
229
  ),
193
- ...PAGINATION,
230
+ count: z.number().int().min(1).max(200).optional().describe(
231
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
232
+ ),
233
+ cursor: z.string().optional().describe(
234
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
235
+ ),
194
236
  },
195
237
  },
196
238
  {
@@ -202,44 +244,113 @@ export const TOOLS = [
202
244
  user_id: z.string().describe(
203
245
  "Numeric Twitter/X user id (e.g. '44196397'). Required: this endpoint does not accept a username. Resolve a handle to a user_id first with twitter_user_info.",
204
246
  ),
205
- ...PAGINATION,
247
+ count: z.number().int().min(1).max(200).optional().describe(
248
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
249
+ ),
250
+ cursor: z.string().optional().describe(
251
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
252
+ ),
206
253
  },
207
254
  },
208
- // ── Reads: followers / following graph ─────────────────────────────────────
209
255
  {
210
256
  name: "twitter_user_followers",
211
257
  path: "/twitter/user/followers",
212
258
  description:
213
259
  "List the accounts that follow a given user. Returns profile data for each follower (username, display name, bio, follower count). Paginate with cursor for large audiences. Useful for audience analysis, finding who follows a brand or influencer.",
214
- shape: { ...USER_REF, ...PAGINATION },
260
+ shape: {
261
+ username: z.string().optional().describe(
262
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
263
+ ),
264
+ user_id: z.string().optional().describe(
265
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
266
+ ),
267
+ count: z.number().int().min(1).max(200).optional().describe(
268
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
269
+ ),
270
+ cursor: z.string().optional().describe(
271
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
272
+ ),
273
+ },
215
274
  },
216
275
  {
217
276
  name: "twitter_user_following",
218
277
  path: "/twitter/user/following",
219
278
  description:
220
279
  "List the accounts that a given user follows. Returns profile data for each account followed. Paginate with cursor. Useful for mapping a user's information sources, influencer networks, or competitor monitoring lists.",
221
- shape: { ...USER_REF, ...PAGINATION },
280
+ shape: {
281
+ username: z.string().optional().describe(
282
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
283
+ ),
284
+ user_id: z.string().optional().describe(
285
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
286
+ ),
287
+ count: z.number().int().min(1).max(200).optional().describe(
288
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
289
+ ),
290
+ cursor: z.string().optional().describe(
291
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
292
+ ),
293
+ },
222
294
  },
223
295
  {
224
296
  name: "twitter_user_followers_v2",
225
297
  path: "/twitter/user/followers_v2",
226
298
  description:
227
299
  "List a user's followers using the v2 response shape (richer profile fields and more reliable cursoring for large audiences). Same inputs as twitter_user_followers; prefer this when you need the fuller v2 payload or are paging deep follower lists.",
228
- shape: { ...USER_REF, ...PAGINATION },
300
+ shape: {
301
+ username: z.string().optional().describe(
302
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
303
+ ),
304
+ user_id: z.string().optional().describe(
305
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
306
+ ),
307
+ count: z.number().int().min(1).max(200).optional().describe(
308
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
309
+ ),
310
+ cursor: z.string().optional().describe(
311
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
312
+ ),
313
+ },
229
314
  },
230
315
  {
231
316
  name: "twitter_user_following_v2",
232
317
  path: "/twitter/user/following_v2",
233
318
  description:
234
319
  "List the accounts a user follows using the v2 response shape (richer profile fields and more reliable cursoring). Same inputs as twitter_user_following; prefer this when you need the fuller v2 payload or are paging deep following lists.",
235
- shape: { ...USER_REF, ...PAGINATION },
320
+ shape: {
321
+ username: z.string().optional().describe(
322
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
323
+ ),
324
+ user_id: z.string().optional().describe(
325
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
326
+ ),
327
+ count: z.number().int().min(1).max(200).optional().describe(
328
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
329
+ ),
330
+ cursor: z.string().optional().describe(
331
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
332
+ ),
333
+ },
236
334
  },
237
335
  {
238
336
  name: "twitter_user_verified_followers",
239
337
  path: "/twitter/user/verified_followers",
240
338
  description:
241
339
  "List a user's followers who have a verified account (checkmark). Filters the follower list to verified accounts only, useful for identifying notable or institutional followers. Paginate with cursor.",
242
- shape: { ...USER_REF, ...PAGINATION },
340
+ shape: {
341
+ username: z.string().optional().describe(
342
+ "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
343
+ ),
344
+ user_id: z.string().optional().describe(
345
+ "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
346
+ ),
347
+ count: z.number().int().min(1).max(200).optional().describe(
348
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
349
+ ),
350
+ cursor: z.string().optional().describe(
351
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
352
+ ),
353
+ },
243
354
  },
244
355
  {
245
356
  name: "twitter_followers_you_know",
@@ -250,42 +361,90 @@ export const TOOLS = [
250
361
  user_id: z.string().describe(
251
362
  "Numeric user id of the target account to compute shared followers against.",
252
363
  ),
253
- ...PAGINATION,
254
- ...INLINE,
364
+ count: z.number().int().min(1).max(200).optional().describe(
365
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
366
+ ),
367
+ cursor: z.string().optional().describe(
368
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
369
+ ),
370
+ auth_token: z.string().optional().describe(
371
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
372
+ ),
373
+ ct0: z.string().optional().describe(
374
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
375
+ ),
376
+ proxy_url: z.string().optional().describe(
377
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
378
+ ),
379
+ user_agent: z.string().optional().describe(
380
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
381
+ ),
255
382
  },
256
383
  },
257
- // ── Reads: a single tweet + its conversation ───────────────────────────────
258
384
  {
259
385
  name: "twitter_tweet_detail",
260
386
  path: "/twitter/tweet/detail",
261
387
  description:
262
388
  "Get the full detail of a single tweet: text, author profile, post timestamp, like/retweet/reply/quote counts, attached media, referenced quoted tweet, and parent reply context. Use this to inspect a specific tweet before fetching its replies or thread. Accepts either the tweet id or its full URL.",
263
- shape: { ...TWEET_REF },
389
+ shape: {
390
+ id: z.string().optional().describe(
391
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
392
+ ),
393
+ url: z.string().optional().describe(
394
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
395
+ ),
396
+ },
264
397
  },
265
398
  {
266
399
  name: "twitter_tweet_replies",
267
400
  path: "/twitter/tweet/replies",
268
401
  description:
269
402
  "Get replies to a specific tweet. Returns each reply tweet with author, text, and metrics. Paginate with cursor to load more. Use this to read the conversation under a tweet, gauge sentiment, or find notable responses.",
270
- shape: { ...TWEET_REF, ...CURSOR },
403
+ shape: {
404
+ id: z.string().optional().describe(
405
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
406
+ ),
407
+ url: z.string().optional().describe(
408
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
409
+ ),
410
+ cursor: z.string().optional().describe(
411
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
412
+ ),
413
+ },
271
414
  },
272
415
  {
273
416
  name: "twitter_tweet_thread",
274
417
  path: "/twitter/tweet/thread",
275
418
  description:
276
419
  "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.",
277
- // No cursor: /twitter/tweet/thread returns the whole ordered thread in one
278
- // response and takes no pagination param (openapi lists only id/url). The
279
- // previous ...CURSOR advertised a cursor the endpoint ignores and drove a
280
- // false "paginate with cursor" claim; removed to match the real contract.
281
- shape: { ...TWEET_REF },
420
+ shape: {
421
+ id: z.string().optional().describe(
422
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
423
+ ),
424
+ url: z.string().optional().describe(
425
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
426
+ ),
427
+ },
282
428
  },
283
429
  {
284
430
  name: "twitter_tweet_retweeters",
285
431
  path: "/twitter/tweet/retweeters",
286
432
  description:
287
433
  "List the accounts that retweeted a specific tweet. Returns profile data for each retweeter. Paginate with cursor. Useful for finding who amplified a piece of content or mapping a tweet's distribution network.",
288
- shape: { ...TWEET_REF, ...PAGINATION },
434
+ shape: {
435
+ id: z.string().optional().describe(
436
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
437
+ ),
438
+ url: z.string().optional().describe(
439
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
440
+ ),
441
+ count: z.number().int().min(1).max(200).optional().describe(
442
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
443
+ ),
444
+ cursor: z.string().optional().describe(
445
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
446
+ ),
447
+ },
289
448
  },
290
449
  {
291
450
  name: "twitter_list_members",
@@ -296,10 +455,14 @@ export const TOOLS = [
296
455
  list_id: z.string().describe(
297
456
  "Numeric Twitter/X List id. Found in the list URL: x.com/i/lists/<list_id>.",
298
457
  ),
299
- ...PAGINATION,
458
+ count: z.number().int().min(1).max(200).optional().describe(
459
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
460
+ ),
461
+ cursor: z.string().optional().describe(
462
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
463
+ ),
300
464
  },
301
465
  },
302
- // ── Reads: trends ──────────────────────────────────────────────────────────
303
466
  {
304
467
  name: "twitter_trends",
305
468
  path: "/twitter/trends",
@@ -324,7 +487,6 @@ export const TOOLS = [
324
487
  "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.",
325
488
  shape: {},
326
489
  },
327
- // ── Reads: your twitterapis.com account (billing; not Twitter data) ─────────
328
490
  {
329
491
  name: "twitter_account_me",
330
492
  path: "/account/me",
@@ -339,20 +501,109 @@ export const TOOLS = [
339
501
  "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).",
340
502
  shape: {},
341
503
  },
342
- // ── Reads: authenticated-account surfaces (require a session behind your key) ─
343
504
  {
344
505
  name: "twitter_home_timeline",
345
506
  path: "/twitter/user/home_timeline",
346
507
  description:
347
508
  "Get YOUR authenticated account's Home timeline (the 'Following'/'For you' feed), most recent first. Requires an authenticated session behind your key. Returns tweets with author and metrics plus a cursor. Use this to read what your account would see when it opens X.",
348
- shape: { ...PAGINATION, ...INLINE },
509
+ shape: {
510
+ count: z.number().int().min(1).max(200).optional().describe(
511
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
512
+ ),
513
+ cursor: z.string().optional().describe(
514
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
515
+ ),
516
+ auth_token: z.string().optional().describe(
517
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
518
+ ),
519
+ ct0: z.string().optional().describe(
520
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
521
+ ),
522
+ proxy_url: z.string().optional().describe(
523
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
524
+ ),
525
+ user_agent: z.string().optional().describe(
526
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
527
+ ),
528
+ },
349
529
  },
350
530
  {
351
531
  name: "twitter_bookmarks",
352
532
  path: "/twitter/user/bookmarks",
353
533
  description:
354
534
  "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.",
355
- shape: { ...PAGINATION, ...INLINE },
535
+ shape: {
536
+ count: z.number().int().min(1).max(200).optional().describe(
537
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
538
+ ),
539
+ cursor: z.string().optional().describe(
540
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
541
+ ),
542
+ auth_token: z.string().optional().describe(
543
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
544
+ ),
545
+ ct0: z.string().optional().describe(
546
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
547
+ ),
548
+ proxy_url: z.string().optional().describe(
549
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
550
+ ),
551
+ user_agent: z.string().optional().describe(
552
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
553
+ ),
554
+ },
555
+ },
556
+ {
557
+ name: "twitter_blocking",
558
+ path: "/twitter/user/blocking",
559
+ description:
560
+ "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.",
561
+ shape: {
562
+ count: z.number().int().min(1).max(200).optional().describe(
563
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
564
+ ),
565
+ cursor: z.string().optional().describe(
566
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
567
+ ),
568
+ auth_token: z.string().optional().describe(
569
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
570
+ ),
571
+ ct0: z.string().optional().describe(
572
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
573
+ ),
574
+ proxy_url: z.string().optional().describe(
575
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
576
+ ),
577
+ user_agent: z.string().optional().describe(
578
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
579
+ ),
580
+ },
581
+ },
582
+ {
583
+ name: "twitter_muting",
584
+ path: "/twitter/user/muting",
585
+ description:
586
+ "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.",
587
+ shape: {
588
+ count: z.number().int().min(1).max(200).optional().describe(
589
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
590
+ ),
591
+ cursor: z.string().optional().describe(
592
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
593
+ ),
594
+ auth_token: z.string().optional().describe(
595
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
596
+ ),
597
+ ct0: z.string().optional().describe(
598
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
599
+ ),
600
+ proxy_url: z.string().optional().describe(
601
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
602
+ ),
603
+ user_agent: z.string().optional().describe(
604
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
605
+ ),
606
+ },
356
607
  },
357
608
  {
358
609
  name: "twitter_bookmark_search",
@@ -363,8 +614,24 @@ export const TOOLS = [
363
614
  query: z.string().describe(
364
615
  "Search terms to match against your bookmarked tweets' text.",
365
616
  ),
366
- ...PAGINATION,
367
- ...INLINE,
617
+ count: z.number().int().min(1).max(200).optional().describe(
618
+ "Max items to return for this page. Typical range 1 to 200; endpoint default (20) applies if omitted. To page through results, pass the cursor from the previous response.",
619
+ ),
620
+ cursor: z.string().optional().describe(
621
+ "Opaque pagination cursor from a previous response's next_cursor field. Omit on the first call; pass on subsequent calls to fetch the next page.",
622
+ ),
623
+ auth_token: z.string().optional().describe(
624
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
625
+ ),
626
+ ct0: z.string().optional().describe(
627
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
628
+ ),
629
+ proxy_url: z.string().optional().describe(
630
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
631
+ ),
632
+ user_agent: z.string().optional().describe(
633
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
634
+ ),
368
635
  },
369
636
  },
370
637
  {
@@ -372,7 +639,20 @@ export const TOOLS = [
372
639
  path: "/twitter/dm/list",
373
640
  description:
374
641
  "List YOUR authenticated account's Direct Message conversations (inbox), each with the participant and a conversation_id you can pass to twitter_dm_conversation. Requires an authenticated session behind your key. Read-only: this does not send DMs.",
375
- shape: { ...INLINE },
642
+ shape: {
643
+ auth_token: z.string().optional().describe(
644
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
645
+ ),
646
+ ct0: z.string().optional().describe(
647
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
648
+ ),
649
+ proxy_url: z.string().optional().describe(
650
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
651
+ ),
652
+ user_agent: z.string().optional().describe(
653
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
654
+ ),
655
+ },
376
656
  },
377
657
  {
378
658
  name: "twitter_dm_conversation",
@@ -383,7 +663,18 @@ export const TOOLS = [
383
663
  conversation_id: z.string().describe(
384
664
  "The conversation_id from a twitter_dm_list entry identifying which DM thread to read.",
385
665
  ),
386
- ...INLINE,
666
+ auth_token: z.string().optional().describe(
667
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
668
+ ),
669
+ ct0: z.string().optional().describe(
670
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
671
+ ),
672
+ proxy_url: z.string().optional().describe(
673
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
674
+ ),
675
+ user_agent: z.string().optional().describe(
676
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
677
+ ),
387
678
  },
388
679
  },
389
680
  {
@@ -400,11 +691,20 @@ export const TOOLS = [
400
691
  text: z.string().min(1).describe(
401
692
  "The Direct Message body text to send (non-empty).",
402
693
  ),
403
- ...INLINE,
694
+ auth_token: z.string().optional().describe(
695
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
696
+ ),
697
+ ct0: z.string().optional().describe(
698
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
699
+ ),
700
+ proxy_url: z.string().optional().describe(
701
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
702
+ ),
703
+ user_agent: z.string().optional().describe(
704
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
705
+ ),
404
706
  },
405
707
  },
406
-
407
- // ── Writes: tweet authoring ────────────────────────────────────────────────
408
708
  {
409
709
  name: "twitter_create_tweet",
410
710
  path: "/twitter/tweet/create",
@@ -425,7 +725,18 @@ export const TOOLS = [
425
725
  media_ids: z.string().optional().describe(
426
726
  "Optional. Comma-separated media id(s) from a prior media upload to attach (images/video).",
427
727
  ),
428
- ...INLINE,
728
+ auth_token: z.string().optional().describe(
729
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
730
+ ),
731
+ ct0: z.string().optional().describe(
732
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
733
+ ),
734
+ proxy_url: z.string().optional().describe(
735
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
736
+ ),
737
+ user_agent: z.string().optional().describe(
738
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
739
+ ),
429
740
  },
430
741
  },
431
742
  {
@@ -436,9 +747,27 @@ export const TOOLS = [
436
747
  destructive: true,
437
748
  description:
438
749
  "Delete a tweet AS your authenticated account. Irreversible: the tweet is permanently removed. You can only delete tweets your authenticated account authored. Provide the tweet id or url. Requires write capability behind your key.",
439
- shape: { ...TWEET_REF, ...INLINE },
750
+ shape: {
751
+ id: z.string().optional().describe(
752
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
753
+ ),
754
+ url: z.string().optional().describe(
755
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
756
+ ),
757
+ auth_token: z.string().optional().describe(
758
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
759
+ ),
760
+ ct0: z.string().optional().describe(
761
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
762
+ ),
763
+ proxy_url: z.string().optional().describe(
764
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
765
+ ),
766
+ user_agent: z.string().optional().describe(
767
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
768
+ ),
769
+ },
440
770
  },
441
- // ── Writes: engagement (favorite / retweet / bookmark) + inverses ──────────
442
771
  {
443
772
  name: "twitter_favorite_tweet",
444
773
  path: "/twitter/tweet/favorite",
@@ -446,7 +775,26 @@ export const TOOLS = [
446
775
  write: true,
447
776
  description:
448
777
  "Like (favorite) a tweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key. Reverse with twitter_unfavorite_tweet.",
449
- shape: { ...TWEET_REF, ...INLINE },
778
+ shape: {
779
+ id: z.string().optional().describe(
780
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
781
+ ),
782
+ url: z.string().optional().describe(
783
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
784
+ ),
785
+ auth_token: z.string().optional().describe(
786
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
787
+ ),
788
+ ct0: z.string().optional().describe(
789
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
790
+ ),
791
+ proxy_url: z.string().optional().describe(
792
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
793
+ ),
794
+ user_agent: z.string().optional().describe(
795
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
796
+ ),
797
+ },
450
798
  },
451
799
  {
452
800
  name: "twitter_unfavorite_tweet",
@@ -456,7 +804,26 @@ export const TOOLS = [
456
804
  destructive: true,
457
805
  description:
458
806
  "Remove a like (unfavorite) from a tweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key.",
459
- shape: { ...TWEET_REF, ...INLINE },
807
+ shape: {
808
+ id: z.string().optional().describe(
809
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
810
+ ),
811
+ url: z.string().optional().describe(
812
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
813
+ ),
814
+ auth_token: z.string().optional().describe(
815
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
816
+ ),
817
+ ct0: z.string().optional().describe(
818
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
819
+ ),
820
+ proxy_url: z.string().optional().describe(
821
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
822
+ ),
823
+ user_agent: z.string().optional().describe(
824
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
825
+ ),
826
+ },
460
827
  },
461
828
  {
462
829
  name: "twitter_retweet",
@@ -465,7 +832,26 @@ export const TOOLS = [
465
832
  write: true,
466
833
  description:
467
834
  "Retweet a tweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key. Reverse with twitter_unretweet.",
468
- shape: { ...TWEET_REF, ...INLINE },
835
+ shape: {
836
+ id: z.string().optional().describe(
837
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
838
+ ),
839
+ url: z.string().optional().describe(
840
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
841
+ ),
842
+ auth_token: z.string().optional().describe(
843
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
844
+ ),
845
+ ct0: z.string().optional().describe(
846
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
847
+ ),
848
+ proxy_url: z.string().optional().describe(
849
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
850
+ ),
851
+ user_agent: z.string().optional().describe(
852
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
853
+ ),
854
+ },
469
855
  },
470
856
  {
471
857
  name: "twitter_unretweet",
@@ -475,7 +861,26 @@ export const TOOLS = [
475
861
  destructive: true,
476
862
  description:
477
863
  "Undo a retweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key.",
478
- shape: { ...TWEET_REF, ...INLINE },
864
+ shape: {
865
+ id: z.string().optional().describe(
866
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
867
+ ),
868
+ url: z.string().optional().describe(
869
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
870
+ ),
871
+ auth_token: z.string().optional().describe(
872
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
873
+ ),
874
+ ct0: z.string().optional().describe(
875
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
876
+ ),
877
+ proxy_url: z.string().optional().describe(
878
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
879
+ ),
880
+ user_agent: z.string().optional().describe(
881
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
882
+ ),
883
+ },
479
884
  },
480
885
  {
481
886
  name: "twitter_bookmark_tweet",
@@ -484,7 +889,26 @@ export const TOOLS = [
484
889
  write: true,
485
890
  description:
486
891
  "Bookmark a tweet to YOUR authenticated account's private bookmarks. Provide the tweet id or url. Requires write capability behind your key. Reverse with twitter_unbookmark_tweet.",
487
- shape: { ...TWEET_REF, ...INLINE },
892
+ shape: {
893
+ id: z.string().optional().describe(
894
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
895
+ ),
896
+ url: z.string().optional().describe(
897
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
898
+ ),
899
+ auth_token: z.string().optional().describe(
900
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
901
+ ),
902
+ ct0: z.string().optional().describe(
903
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
904
+ ),
905
+ proxy_url: z.string().optional().describe(
906
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
907
+ ),
908
+ user_agent: z.string().optional().describe(
909
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
910
+ ),
911
+ },
488
912
  },
489
913
  {
490
914
  name: "twitter_unbookmark_tweet",
@@ -494,9 +918,27 @@ export const TOOLS = [
494
918
  destructive: true,
495
919
  description:
496
920
  "Remove a tweet from YOUR authenticated account's bookmarks. Provide the tweet id or url. Requires write capability behind your key.",
497
- shape: { ...TWEET_REF, ...INLINE },
921
+ shape: {
922
+ id: z.string().optional().describe(
923
+ "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",
924
+ ),
925
+ url: z.string().optional().describe(
926
+ "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
927
+ ),
928
+ auth_token: z.string().optional().describe(
929
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
930
+ ),
931
+ ct0: z.string().optional().describe(
932
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
933
+ ),
934
+ proxy_url: z.string().optional().describe(
935
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
936
+ ),
937
+ user_agent: z.string().optional().describe(
938
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
939
+ ),
940
+ },
498
941
  },
499
- // ── Writes: follow graph ───────────────────────────────────────────────────
500
942
  {
501
943
  name: "twitter_follow_user",
502
944
  path: "/twitter/user/follow",
@@ -508,7 +950,18 @@ export const TOOLS = [
508
950
  user_id: z.string().describe(
509
951
  "Numeric user id of the account to follow. Resolve a handle to a user_id first with twitter_user_info.",
510
952
  ),
511
- ...INLINE,
953
+ auth_token: z.string().optional().describe(
954
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
955
+ ),
956
+ ct0: z.string().optional().describe(
957
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
958
+ ),
959
+ proxy_url: z.string().optional().describe(
960
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
961
+ ),
962
+ user_agent: z.string().optional().describe(
963
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
964
+ ),
512
965
  },
513
966
  },
514
967
  {
@@ -523,15 +976,20 @@ export const TOOLS = [
523
976
  user_id: z.string().describe(
524
977
  "Numeric user id of the account to unfollow.",
525
978
  ),
526
- ...INLINE,
979
+ auth_token: z.string().optional().describe(
980
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
981
+ ),
982
+ ct0: z.string().optional().describe(
983
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
984
+ ),
985
+ proxy_url: z.string().optional().describe(
986
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
987
+ ),
988
+ user_agent: z.string().optional().describe(
989
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
990
+ ),
527
991
  },
528
992
  },
529
- // ── Session bootstrap + media: link an X account to your key, then act as it ─
530
- // Once a session is linked (via twitter_customer_session or twitter_user_login)
531
- // the authenticated-account reads and the write actions run AS that account.
532
- // These three send a JSON request body (jsonBody:true), so the fields travel
533
- // in the body, not the query string, matching the backend routes that read
534
- // c.req.json().
535
993
  {
536
994
  name: "twitter_customer_session",
537
995
  path: "/twitter/customer/session",
@@ -561,12 +1019,6 @@ export const TOOLS = [
561
1019
  method: "POST",
562
1020
  write: true,
563
1021
  jsonBody: true,
564
- // CONTRACT NOTE (maintainers): the published openapi documents an
565
- // {auth_token, ct0, twid} response for this endpoint. That is WRONG. The
566
- // live handler (backend routes/user-login.ts) returns {ok, username,
567
- // message} and stores the minted session server-side; it never returns the
568
- // cookies. The description below documents the REAL contract, not the
569
- // openapi's. Fixing the openapi response schema is a docs/website change.
570
1022
  description:
571
1023
  "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.",
572
1024
  shape: {
@@ -593,16 +1045,45 @@ export const TOOLS = [
593
1045
  media_data: z.string().describe(
594
1046
  "Base64-encoded image bytes to upload. Sent in the JSON request body.",
595
1047
  ),
596
- ...INLINE,
1048
+ auth_token: z.string().optional().describe(
1049
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
1050
+ ),
1051
+ ct0: z.string().optional().describe(
1052
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
1053
+ ),
1054
+ proxy_url: z.string().optional().describe(
1055
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
1056
+ ),
1057
+ user_agent: z.string().optional().describe(
1058
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
1059
+ ),
1060
+ },
1061
+ },
1062
+ {
1063
+ name: "twitter_media_status",
1064
+ path: "/twitter/media/status",
1065
+ description:
1066
+ "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.",
1067
+ shape: {
1068
+ media_id: z.string().describe(
1069
+ "Numeric media id returned by twitter_media_upload, e.g. '1234567890123456789'.",
1070
+ ),
1071
+ auth_token: z.string().optional().describe(
1072
+ "Optional. The account's auth_token cookie, to act AS that account for this call (must be paired with ct0). Sent as the x-auth-token header; never placed in the URL.",
1073
+ ),
1074
+ ct0: z.string().optional().describe(
1075
+ "Optional. The account's ct0 cookie, paired with auth_token. Sent as the x-ct0 header.",
1076
+ ),
1077
+ proxy_url: z.string().optional().describe(
1078
+ "Optional. Residential proxy URL to egress this call through. Recommended for writes: X soft-blocks writes from datacenter IPs as automated. Sent as the x-proxy-url header.",
1079
+ ),
1080
+ user_agent: z.string().optional().describe(
1081
+ "Optional. User-Agent string to send for this session. Sent as the x-user-agent header.",
1082
+ ),
597
1083
  },
598
1084
  },
599
1085
  ];
600
1086
 
601
- // Pure query-string builder: drops undefined/null/empty values, URL-encodes the rest.
602
- export function buildQuery(args) {
603
- const qs = new URLSearchParams();
604
- for (const [k, v] of Object.entries(args || {})) {
605
- if (v !== undefined && v !== null && String(v).length > 0) qs.set(k, String(v));
606
- }
607
- return qs.toString();
608
- }
1087
+ // The query-string builder is hand-written logic, not catalog data, so it lives
1088
+ // in its own module and is re-exported here to keep this file's one import path.
1089
+ export { buildQuery } from "./query.js";