@twitterapis/mcp 0.6.3 → 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,29 @@
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
+
3
27
  ## 0.6.3 (2026-08-02)
4
28
 
5
29
  ### Added
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.3",
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,8 +23,11 @@
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 && node test/registry-manifests.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
33
  "check:firewall": "node test/firewall.mjs",
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
  {
@@ -127,7 +98,14 @@ export const TOOLS = [
127
98
  path: "/twitter/user/user_about",
128
99
  description:
129
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.",
130
- 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
+ },
131
109
  },
132
110
  {
133
111
  name: "twitter_user_affiliates",
@@ -135,11 +113,21 @@ export const TOOLS = [
135
113
  description:
136
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.",
137
115
  shape: {
138
- ...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
+ ),
139
122
  team: z.string().optional().describe(
140
123
  "Optional team/sub-group name to filter affiliates by, when the org exposes named teams.",
141
124
  ),
142
- ...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
+ ),
143
131
  },
144
132
  },
145
133
  {
@@ -156,20 +144,45 @@ export const TOOLS = [
156
144
  ),
157
145
  },
158
146
  },
159
- // ── Reads: a user's tweets / timeline ──────────────────────────────────────
160
147
  {
161
148
  name: "twitter_user_tweets",
162
149
  path: "/twitter/user/tweets",
163
150
  description:
164
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.",
165
- 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
+ },
166
166
  },
167
167
  {
168
168
  name: "twitter_user_tweets_and_replies",
169
169
  path: "/twitter/user/tweets_and_replies",
170
170
  description:
171
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.",
172
- 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
+ },
173
186
  },
174
187
  {
175
188
  name: "twitter_user_tweets_complete",
@@ -190,7 +203,20 @@ export const TOOLS = [
190
203
  path: "/twitter/user/media",
191
204
  description:
192
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.",
193
- 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
+ },
194
220
  },
195
221
  {
196
222
  name: "twitter_user_mentions",
@@ -201,7 +227,12 @@ export const TOOLS = [
201
227
  username: z.string().describe(
202
228
  "Twitter/X handle WITHOUT the leading @ of the user to find mentions for (e.g. 'openai' to find tweets mentioning @openai).",
203
229
  ),
204
- ...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
+ ),
205
236
  },
206
237
  },
207
238
  {
@@ -213,44 +244,113 @@ export const TOOLS = [
213
244
  user_id: z.string().describe(
214
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.",
215
246
  ),
216
- ...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
+ ),
217
253
  },
218
254
  },
219
- // ── Reads: followers / following graph ─────────────────────────────────────
220
255
  {
221
256
  name: "twitter_user_followers",
222
257
  path: "/twitter/user/followers",
223
258
  description:
224
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.",
225
- 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
+ },
226
274
  },
227
275
  {
228
276
  name: "twitter_user_following",
229
277
  path: "/twitter/user/following",
230
278
  description:
231
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.",
232
- 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
+ },
233
294
  },
234
295
  {
235
296
  name: "twitter_user_followers_v2",
236
297
  path: "/twitter/user/followers_v2",
237
298
  description:
238
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.",
239
- 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
+ },
240
314
  },
241
315
  {
242
316
  name: "twitter_user_following_v2",
243
317
  path: "/twitter/user/following_v2",
244
318
  description:
245
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.",
246
- 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
+ },
247
334
  },
248
335
  {
249
336
  name: "twitter_user_verified_followers",
250
337
  path: "/twitter/user/verified_followers",
251
338
  description:
252
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.",
253
- 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
+ },
254
354
  },
255
355
  {
256
356
  name: "twitter_followers_you_know",
@@ -261,42 +361,90 @@ export const TOOLS = [
261
361
  user_id: z.string().describe(
262
362
  "Numeric user id of the target account to compute shared followers against.",
263
363
  ),
264
- ...PAGINATION,
265
- ...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
+ ),
266
382
  },
267
383
  },
268
- // ── Reads: a single tweet + its conversation ───────────────────────────────
269
384
  {
270
385
  name: "twitter_tweet_detail",
271
386
  path: "/twitter/tweet/detail",
272
387
  description:
273
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.",
274
- 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
+ },
275
397
  },
276
398
  {
277
399
  name: "twitter_tweet_replies",
278
400
  path: "/twitter/tweet/replies",
279
401
  description:
280
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.",
281
- 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
+ },
282
414
  },
283
415
  {
284
416
  name: "twitter_tweet_thread",
285
417
  path: "/twitter/tweet/thread",
286
418
  description:
287
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.",
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 },
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
+ },
293
428
  },
294
429
  {
295
430
  name: "twitter_tweet_retweeters",
296
431
  path: "/twitter/tweet/retweeters",
297
432
  description:
298
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.",
299
- 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
+ },
300
448
  },
301
449
  {
302
450
  name: "twitter_list_members",
@@ -307,10 +455,14 @@ export const TOOLS = [
307
455
  list_id: z.string().describe(
308
456
  "Numeric Twitter/X List id. Found in the list URL: x.com/i/lists/<list_id>.",
309
457
  ),
310
- ...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
+ ),
311
464
  },
312
465
  },
313
- // ── Reads: trends ──────────────────────────────────────────────────────────
314
466
  {
315
467
  name: "twitter_trends",
316
468
  path: "/twitter/trends",
@@ -335,7 +487,6 @@ export const TOOLS = [
335
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.",
336
488
  shape: {},
337
489
  },
338
- // ── Reads: your twitterapis.com account (billing; not Twitter data) ─────────
339
490
  {
340
491
  name: "twitter_account_me",
341
492
  path: "/account/me",
@@ -350,34 +501,109 @@ export const TOOLS = [
350
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).",
351
502
  shape: {},
352
503
  },
353
- // ── Reads: authenticated-account surfaces (require a session behind your key) ─
354
504
  {
355
505
  name: "twitter_home_timeline",
356
506
  path: "/twitter/user/home_timeline",
357
507
  description:
358
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.",
359
- 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
+ },
360
529
  },
361
530
  {
362
531
  name: "twitter_bookmarks",
363
532
  path: "/twitter/user/bookmarks",
364
533
  description:
365
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.",
366
- 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
+ },
367
555
  },
368
556
  {
369
557
  name: "twitter_blocking",
370
558
  path: "/twitter/user/blocking",
371
559
  description:
372
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.",
373
- shape: { ...PAGINATION, ...INLINE },
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
+ },
374
581
  },
375
582
  {
376
583
  name: "twitter_muting",
377
584
  path: "/twitter/user/muting",
378
585
  description:
379
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.",
380
- shape: { ...PAGINATION, ...INLINE },
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
+ },
381
607
  },
382
608
  {
383
609
  name: "twitter_bookmark_search",
@@ -388,8 +614,24 @@ export const TOOLS = [
388
614
  query: z.string().describe(
389
615
  "Search terms to match against your bookmarked tweets' text.",
390
616
  ),
391
- ...PAGINATION,
392
- ...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
+ ),
393
635
  },
394
636
  },
395
637
  {
@@ -397,7 +639,20 @@ export const TOOLS = [
397
639
  path: "/twitter/dm/list",
398
640
  description:
399
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.",
400
- 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
+ },
401
656
  },
402
657
  {
403
658
  name: "twitter_dm_conversation",
@@ -408,7 +663,18 @@ export const TOOLS = [
408
663
  conversation_id: z.string().describe(
409
664
  "The conversation_id from a twitter_dm_list entry identifying which DM thread to read.",
410
665
  ),
411
- ...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
+ ),
412
678
  },
413
679
  },
414
680
  {
@@ -425,11 +691,20 @@ export const TOOLS = [
425
691
  text: z.string().min(1).describe(
426
692
  "The Direct Message body text to send (non-empty).",
427
693
  ),
428
- ...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
+ ),
429
706
  },
430
707
  },
431
-
432
- // ── Writes: tweet authoring ────────────────────────────────────────────────
433
708
  {
434
709
  name: "twitter_create_tweet",
435
710
  path: "/twitter/tweet/create",
@@ -450,7 +725,18 @@ export const TOOLS = [
450
725
  media_ids: z.string().optional().describe(
451
726
  "Optional. Comma-separated media id(s) from a prior media upload to attach (images/video).",
452
727
  ),
453
- ...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
+ ),
454
740
  },
455
741
  },
456
742
  {
@@ -461,9 +747,27 @@ export const TOOLS = [
461
747
  destructive: true,
462
748
  description:
463
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.",
464
- 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
+ },
465
770
  },
466
- // ── Writes: engagement (favorite / retweet / bookmark) + inverses ──────────
467
771
  {
468
772
  name: "twitter_favorite_tweet",
469
773
  path: "/twitter/tweet/favorite",
@@ -471,7 +775,26 @@ export const TOOLS = [
471
775
  write: true,
472
776
  description:
473
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.",
474
- 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
+ },
475
798
  },
476
799
  {
477
800
  name: "twitter_unfavorite_tweet",
@@ -481,7 +804,26 @@ export const TOOLS = [
481
804
  destructive: true,
482
805
  description:
483
806
  "Remove a like (unfavorite) from a tweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key.",
484
- 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
+ },
485
827
  },
486
828
  {
487
829
  name: "twitter_retweet",
@@ -490,7 +832,26 @@ export const TOOLS = [
490
832
  write: true,
491
833
  description:
492
834
  "Retweet a tweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key. Reverse with twitter_unretweet.",
493
- 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
+ },
494
855
  },
495
856
  {
496
857
  name: "twitter_unretweet",
@@ -500,7 +861,26 @@ export const TOOLS = [
500
861
  destructive: true,
501
862
  description:
502
863
  "Undo a retweet AS your authenticated account. Provide the tweet id or url. Requires write capability behind your key.",
503
- 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
+ },
504
884
  },
505
885
  {
506
886
  name: "twitter_bookmark_tweet",
@@ -509,7 +889,26 @@ export const TOOLS = [
509
889
  write: true,
510
890
  description:
511
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.",
512
- 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
+ },
513
912
  },
514
913
  {
515
914
  name: "twitter_unbookmark_tweet",
@@ -519,9 +918,27 @@ export const TOOLS = [
519
918
  destructive: true,
520
919
  description:
521
920
  "Remove a tweet from YOUR authenticated account's bookmarks. Provide the tweet id or url. Requires write capability behind your key.",
522
- 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
+ },
523
941
  },
524
- // ── Writes: follow graph ───────────────────────────────────────────────────
525
942
  {
526
943
  name: "twitter_follow_user",
527
944
  path: "/twitter/user/follow",
@@ -533,7 +950,18 @@ export const TOOLS = [
533
950
  user_id: z.string().describe(
534
951
  "Numeric user id of the account to follow. Resolve a handle to a user_id first with twitter_user_info.",
535
952
  ),
536
- ...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
+ ),
537
965
  },
538
966
  },
539
967
  {
@@ -548,15 +976,20 @@ export const TOOLS = [
548
976
  user_id: z.string().describe(
549
977
  "Numeric user id of the account to unfollow.",
550
978
  ),
551
- ...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
+ ),
552
991
  },
553
992
  },
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
993
  {
561
994
  name: "twitter_customer_session",
562
995
  path: "/twitter/customer/session",
@@ -586,12 +1019,6 @@ export const TOOLS = [
586
1019
  method: "POST",
587
1020
  write: true,
588
1021
  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
1022
  description:
596
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.",
597
1024
  shape: {
@@ -618,7 +1045,18 @@ export const TOOLS = [
618
1045
  media_data: z.string().describe(
619
1046
  "Base64-encoded image bytes to upload. Sent in the JSON request body.",
620
1047
  ),
621
- ...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
+ ),
622
1060
  },
623
1061
  },
624
1062
  {
@@ -630,16 +1068,22 @@ export const TOOLS = [
630
1068
  media_id: z.string().describe(
631
1069
  "Numeric media id returned by twitter_media_upload, e.g. '1234567890123456789'.",
632
1070
  ),
633
- ...INLINE,
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
+ ),
634
1083
  },
635
1084
  },
636
1085
  ];
637
1086
 
638
- // Pure query-string builder: drops undefined/null/empty values, URL-encodes the rest.
639
- export function buildQuery(args) {
640
- const qs = new URLSearchParams();
641
- for (const [k, v] of Object.entries(args || {})) {
642
- if (v !== undefined && v !== null && String(v).length > 0) qs.set(k, String(v));
643
- }
644
- return qs.toString();
645
- }
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";