@twitterapis/mcp 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-09-30)
4
+
5
+ ### Added
6
+
7
+ - **`paid_promotion` on the 17 tweet-list tools.** `"only"` keeps tweets X labels
8
+ Paid partnership (`is_paid_promotion` true), `"exclude"` keeps the rest. It filters
9
+ the page the API returned, so `next_cursor` still pages on, and the response carries
10
+ `paid_promotion_filter { mode, kept, removed }`. Same cost as without it.
11
+
12
+ ## 0.16.1 (2026-09-29)
13
+
14
+ ### Changed
15
+
16
+ - **A restart answered in JSON is ridden out too.** The gateway now answers a
17
+ restart with a JSON 503 (`"error": "gateway_restarting"`, `Retry-After`)
18
+ instead of its HTML page; a read retries on either. Any other JSON 503 from
19
+ the API, and that code on any other status, is still never retried.
20
+ - **A cancelled call stops.** When the MCP client cancels a tool call, the
21
+ request in flight is aborted and the wait before a retry ends at once; the
22
+ result says "Request cancelled by the caller." instead of reporting a timeout.
23
+
3
24
  ## 0.16.0 (2026-09-29)
4
25
 
5
26
  ### Changed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@twitterapis/mcp",
3
3
  "mcpName": "io.github.TwitterAPIs/twitterapis-mcp",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
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.",
6
6
  "repository": {
7
7
  "type": "git",
@@ -43,7 +43,9 @@
43
43
  "check:manifest-tools": "node scripts/gen-manifest-tools.mjs --check",
44
44
  "bundle": "node scripts/gen-manifest-tools.mjs --write && npx -y @anthropic-ai/mcpb@2.1.2 pack .",
45
45
  "check:mcpb": "npx -y @anthropic-ai/mcpb@2.1.2 validate manifest.json",
46
- "check:publish-provenance": "node test/publish-provenance.mjs"
46
+ "check:publish-provenance": "node test/publish-provenance.mjs",
47
+ "postpublish": "node scripts/registry-drift.mjs --publish",
48
+ "check:registry-drift": "node scripts/registry-drift.mjs"
47
49
  },
48
50
  "dependencies": {
49
51
  "@modelcontextprotocol/sdk": "^1.0.0",
package/src/server.js CHANGED
@@ -204,7 +204,7 @@ export function createServer({
204
204
  // substitute into the URL template) and callEndpoint splices them into path
205
205
  // before building the query string or body, so a pathParams arg never leaks
206
206
  // into either.
207
- async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
207
+ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = [], { signal: callerSignal } = {}) {
208
208
  if (!apiKey && !authHeaders) return paywallResult("no_key");
209
209
  // Fill {name} URL segments from args and strip those keys, so a pathParams arg
210
210
  // (e.g. a monitor/webhook id) never also leaks into the query string or JSON
@@ -272,16 +272,34 @@ export function createServer({
272
272
  // socket dropped mid-answer can all happen after the API did the work, and
273
273
  // "fetch failed" alone also covers DNS and TLS errors that retrying cannot fix.
274
274
  const RETRYABLE_NET = new Set(["ECONNREFUSED"]);
275
- const gatewayFailure = (status, text) =>
276
- (status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text);
275
+ // The gateway answers a restart two ways: nginx's stock HTML 502/503 page,
276
+ // or (since 2026-09-29) its own JSON 503 whose error is "gateway_restarting".
277
+ // Nginx sends that JSON only when it could not reach the API at all, so the
278
+ // API's own JSON errors (which never use that code) are still never retried.
279
+ const gatewayFailure = (status, text) => {
280
+ if ((status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text)) return true;
281
+ if (status !== 503) return false;
282
+ try { return JSON.parse(text)?.error === "gateway_restarting"; } catch { return false; }
283
+ };
284
+ // The MCP caller's cancel (notifications/cancelled) aborts the request in
285
+ // flight and any wait between retries, so a cancelled call stops at once.
286
+ const cancelled = () => Boolean(callerSignal?.aborted);
287
+ const pause = (ms) => cancelled() ? Promise.resolve() : new Promise((resolve) => {
288
+ const done = () => { callerSignal?.removeEventListener?.("abort", done); resolve(); };
289
+ Promise.resolve(sleepImpl(ms)).then(done, done);
290
+ callerSignal?.addEventListener?.("abort", done, { once: true });
291
+ });
277
292
  const transientNetwork = (err) =>
278
293
  !gotResponse && err?.name !== "AbortError" && RETRYABLE_NET.has(err?.cause?.code ?? err?.code);
279
294
  let attempt = 0;
280
295
  let gotResponse = false;
281
296
  for (;;) {
282
297
  gotResponse = false;
298
+ if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
283
299
  const ctrl = new AbortController();
284
300
  const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
301
+ const onCancel = () => ctrl.abort();
302
+ callerSignal?.addEventListener?.("abort", onCancel, { once: true });
285
303
  try {
286
304
  const res = await fetchImpl(url, {
287
305
  method,
@@ -291,9 +309,10 @@ export function createServer({
291
309
  });
292
310
  gotResponse = true;
293
311
  const body = await res.text();
294
- if (canRetry && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
312
+ if (canRetry && !cancelled() && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
295
313
  clearTimeout(timer);
296
- await sleepImpl(retryDelaysMs[attempt++]);
314
+ callerSignal?.removeEventListener?.("abort", onCancel);
315
+ await pause(retryDelaysMs[attempt++]);
297
316
  continue;
298
317
  }
299
318
  if (!res.ok) {
@@ -323,11 +342,13 @@ export function createServer({
323
342
  lastError = null;
324
343
  return { content: [{ type: "text", text: body }] };
325
344
  } catch (err) {
326
- if (canRetry && attempt < retryDelaysMs.length && transientNetwork(err)) {
345
+ if (canRetry && !cancelled() && attempt < retryDelaysMs.length && transientNetwork(err)) {
327
346
  clearTimeout(timer);
328
- await sleepImpl(retryDelaysMs[attempt++]);
347
+ callerSignal?.removeEventListener?.("abort", onCancel);
348
+ await pause(retryDelaysMs[attempt++]);
329
349
  continue;
330
350
  }
351
+ if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
331
352
  const code = err?.cause?.code ?? err?.code;
332
353
  const msg = err?.name === "AbortError"
333
354
  ? `timed out after ${REQUEST_TIMEOUT_MS}ms`
@@ -336,6 +357,7 @@ export function createServer({
336
357
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
337
358
  } finally {
338
359
  clearTimeout(timer);
360
+ callerSignal?.removeEventListener?.("abort", onCancel);
339
361
  }
340
362
  }
341
363
  }
@@ -370,8 +392,8 @@ export function createServer({
370
392
  handler = LOCAL_HANDLERS[tool.local];
371
393
  if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
372
394
  } else {
373
- handler = async (args) => {
374
- const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
395
+ handler = async (args, extra) => {
396
+ const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || [], { signal: extra?.signal });
375
397
  if (result?.isError && lastError) {
376
398
  let resolved = null;
377
399
  try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
package/src/tools.js CHANGED
@@ -55,6 +55,9 @@ export const TOOLS = [
55
55
  compact: z.enum(["1","true"]).optional().describe(
56
56
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
57
57
  ),
58
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
59
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
60
+ ),
58
61
  },
59
62
  },
60
63
  {
@@ -224,6 +227,9 @@ export const TOOLS = [
224
227
  compact: z.enum(["1","true"]).optional().describe(
225
228
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
226
229
  ),
230
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
231
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
232
+ ),
227
233
  },
228
234
  },
229
235
  {
@@ -250,6 +256,9 @@ export const TOOLS = [
250
256
  compact: z.enum(["1","true"]).optional().describe(
251
257
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
252
258
  ),
259
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
260
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
261
+ ),
253
262
  },
254
263
  },
255
264
  {
@@ -273,6 +282,9 @@ export const TOOLS = [
273
282
  compact: z.enum(["1","true"]).optional().describe(
274
283
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
275
284
  ),
285
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
286
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
287
+ ),
276
288
  },
277
289
  },
278
290
  {
@@ -299,6 +311,9 @@ export const TOOLS = [
299
311
  compact: z.enum(["1","true"]).optional().describe(
300
312
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
301
313
  ),
314
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
315
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
316
+ ),
302
317
  },
303
318
  },
304
319
  {
@@ -322,6 +337,9 @@ export const TOOLS = [
322
337
  compact: z.enum(["1","true"]).optional().describe(
323
338
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
324
339
  ),
340
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
341
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
342
+ ),
325
343
  },
326
344
  },
327
345
  {
@@ -345,6 +363,9 @@ export const TOOLS = [
345
363
  compact: z.enum(["1","true"]).optional().describe(
346
364
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
347
365
  ),
366
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
367
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
368
+ ),
348
369
  },
349
370
  },
350
371
  {
@@ -553,6 +574,9 @@ export const TOOLS = [
553
574
  compact: z.enum(["1","true"]).optional().describe(
554
575
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
555
576
  ),
577
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
578
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
579
+ ),
556
580
  },
557
581
  },
558
582
  {
@@ -573,6 +597,9 @@ export const TOOLS = [
573
597
  compact: z.enum(["1","true"]).optional().describe(
574
598
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
575
599
  ),
600
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
601
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
602
+ ),
576
603
  },
577
604
  },
578
605
  {
@@ -631,6 +658,9 @@ export const TOOLS = [
631
658
  compact: z.enum(["1","true"]).optional().describe(
632
659
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
633
660
  ),
661
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
662
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
663
+ ),
634
664
  },
635
665
  },
636
666
  {
@@ -712,6 +742,9 @@ export const TOOLS = [
712
742
  compact: z.enum(["1","true"]).optional().describe(
713
743
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
714
744
  ),
745
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
746
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
747
+ ),
715
748
  },
716
749
  },
717
750
  {
@@ -735,6 +768,9 @@ export const TOOLS = [
735
768
  compact: z.enum(["1","true"]).optional().describe(
736
769
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
737
770
  ),
771
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
772
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
773
+ ),
738
774
  },
739
775
  },
740
776
  {
@@ -884,6 +920,9 @@ export const TOOLS = [
884
920
  compact: z.enum(["1","true"]).optional().describe(
885
921
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
886
922
  ),
923
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
924
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
925
+ ),
887
926
  },
888
927
  },
889
928
  {
@@ -1119,6 +1158,9 @@ export const TOOLS = [
1119
1158
  compact: z.enum(["1","true"]).optional().describe(
1120
1159
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
1121
1160
  ),
1161
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
1162
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
1163
+ ),
1122
1164
  },
1123
1165
  },
1124
1166
  {
@@ -1151,6 +1193,9 @@ export const TOOLS = [
1151
1193
  compact: z.enum(["1","true"]).optional().describe(
1152
1194
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
1153
1195
  ),
1196
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
1197
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
1198
+ ),
1154
1199
  },
1155
1200
  },
1156
1201
  {
@@ -1250,6 +1295,9 @@ export const TOOLS = [
1250
1295
  compact: z.enum(["1","true"]).optional().describe(
1251
1296
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
1252
1297
  ),
1298
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
1299
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
1300
+ ),
1253
1301
  },
1254
1302
  },
1255
1303
  {
@@ -1308,6 +1356,9 @@ export const TOOLS = [
1308
1356
  compact: z.enum(["1","true"]).optional().describe(
1309
1357
  "Optional. Set to \"1\" for the built-in compact preset: ids, url, text, created_at, lang, engagement counts, the is_retweet/is_reply/is_quote flags, conversation ids, the author's id/username/name/followers_count/verification, and the quoted or retweeted tweet's id/url/author username. Trims what it recognises and, on its own, never turns a body into {}. Combine with fields to keep extra paths (then only the named paths and the envelope keys survive).",
1310
1358
  ),
1359
+ paid_promotion: z.enum(["only","exclude"]).optional().describe(
1360
+ "Optional. Filter this page's tweets by X's Paid partnership label: \"only\" keeps tweets whose is_paid_promotion is true, \"exclude\" keeps the rest. It filters the page X returned and does not fetch more, so a page can hold fewer tweets than asked for, or none, while next_cursor still pages on; the response carries paid_promotion_filter { mode, kept, removed }. A retweet is judged by its own flag, not the retweeted post's. Same cost as without it.",
1361
+ ),
1311
1362
  },
1312
1363
  },
1313
1364
  {