@twitterapis/mcp 0.9.3 → 0.9.6

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.6 (2026-09-04)
4
+
5
+ ### Added
6
+
7
+ - **`twitter_feedback_send` and `twitter_feedback_get`, report a bug or a gap to the twitterapis.com team from inside the session you are already in.** Modelled on Claude Code's own feedback tool: the model drafts a report at a high-signal moment (a call failed in a way that is not your key, credits, session or a rate limit and you had to work around it; you asked for something no tool covers; a documented field came back empty or wrong; you were plainly frustrated with a result) into a local queue at `~/.twitterapis/feedback-queue.json`, and nothing is sent until you review the queue and name the drafts to send. Each draft carries the last failing call's endpoint, status and request id, your MCP client's name and this package's version, filled in automatically, so a report is actionable without a follow-up. `twitter_feedback_send` takes `action` (`draft`, `list`, `send`, `discard`); `twitter_feedback_get` reads a sent report's status and the team's response. Both are free. The trigger list also ships as the server's MCP `instructions`, so a client that honours them nudges its model at the right moments. Every non-credential error body now ends with a one-line pointer to the tool. This takes the catalog to 98 tools, 62 reads and 36 writes, still exact parity with the API's own endpoint count.
8
+ - **Catalog support for local handlers.** A tool may declare `local: "<handler>"` in `scripts/tools.overrides.mjs`, and args flagged `local: true` are consumed in this package instead of being sent to the API. The generator refuses a `local: true` arg on a tool with no handler, and refuses a `local` handler name `src/index.js` does not implement at boot, so neither flag can turn into a silent passthrough. A new `strings` arg type renders `z.array(z.string())`.
9
+
10
+ ## 0.9.5 (2026-08-31)
11
+
12
+ ### Added
13
+
14
+ - **`twitter_monitor_webhook_redrive`, replay the deliveries you missed while your endpoint was down.** A delivery is dead-lettered after it fails all 8 attempts across 21 minutes, so an outage longer than that window loses those events outright. This re-queues them with a full retry budget, oldest first, and is free per call. Bounded by default so a recovered endpoint is not flooded: `max_age_hours` defaults to 24 (1 to 168) and `limit` to 100 (1 to 1000). Returns `requeued` and `skipped_permanent`; a delivery that died for a permanent reason (a 410 Gone, a deleted webhook, or a URL egress refused) is not replayed, because it would fail the same way and spend the budget again. Replayed events carry the same signature and payload as the original, so make your handler idempotent on the event id if a duplicate would matter to you. This takes the catalog to 96 tools, 61 reads and 35 writes, which is exact parity with the API's own endpoint count.
15
+ - **`include_replies` on `twitter_monitor_create` and `twitter_monitor_update`.** The parameter was added upstream and neither tool exposed it, so a caller could not turn replies off through the MCP at all. `true` delivers the account's replies as well as its own posts, which is the default and what every monitor has always done; `false` holds replies back. It must be a real boolean: the string `"false"` and the number `0` are rejected with a 400 rather than coerced, because coercing them would quietly give you the opposite of what you typed, and the wrong answer here is invisible since it looks exactly like the account not having posted. The generator is fail-closed on an unexposed spec param and refused to build until both were declared, which is how this surfaced.
16
+
17
+ ### Fixed
18
+
19
+ - **The redrive tool would have 400'd on every call without `jsonBody: true`.** Its handler reads `max_age_hours` and `limit` from the body only, so the args would have gone out as a query string. Caught by `body-mode-parity`, which reads the backend's own generated route manifest rather than trusting this repo's view of it. That manifest was itself stale on the backend's main branch (the route landed without regenerating it), so the failure surfaced here first and was fixed upstream in twitterapis-backend#408 before this release.
20
+
21
+ ## 0.9.4 (2026-08-19)
22
+
23
+ ### Fixed
24
+
25
+ - **A malformed `TWITTERAPIS_TIMEOUT_MS` silently timed out every single tool call.** `Number(process.env.TWITTERAPIS_TIMEOUT_MS || 30000)` had no validation: a non-numeric value (`"30000ms"`, `"60,000"`, a stray comma or unit) parses to `NaN`, and Node's `setTimeout` clamps a `NaN` delay to about 1ms, so the abort controller fired before any real request could complete. Every tool call failed with `Request failed: timed out after NaNms`, which reads as a live API outage rather than the config typo it actually is. An explicit `0` or negative value had the same effect with no typo required at all. The value is now validated as a finite, positive number before use, falls back to the documented 30000ms default otherwise, and logs a clear warning to stderr naming the bad value instead of silently breaking every call.
26
+ - **8 tool args declared `optional: true` in `scripts/tools.overrides.mjs`, a key the generator never reads** (`with_listeners` / `with_replays` on `twitter_spaces_info`, `message` / `messages` / `conversation_id` / `mode` / `image_count` on `twitter_grok_chat`, `media_category` on `twitter_article_update_cover_media`). The render logic only ever checks `a.required`, so `optional: true` was silently a no-op; each of these 8 args happened to render as optional anyway only because the vendored spec's own `required` flag for that param already defaulted to false. A future spec refresh flipping one of those defaults would have silently made the arg required with no warning from any gate. Renamed to `required: false`, the property the generator actually reads. `src/tools.js` is byte-identical before and after (`catalog-identity` confirms), so this closes a live gap without changing today's behavior.
27
+ - **The generator now fails the build on any unrecognized key in a `tools.overrides.mjs` arg entry** (`scripts/gen-tools.mjs`), so the class of bug above can't recur silently. Red-tested: reintroducing `optional: true` on a synthetic arg makes `npm run build:check` fail with `sets unrecognized key "optional" ... did you mean "required: false"?`.
28
+
3
29
  ## 0.9.3 (2026-08-18)
4
30
 
5
31
  PR #36 (3 new tools + openapi-parity fix) merged after 0.9.2 had already been
package/README.md CHANGED
@@ -91,9 +91,9 @@ Restart Claude Desktop. The `twitter_*` tools appear in the tool picker.
91
91
 
92
92
  ## Tools
93
93
 
94
- 94 tools: 60 reads and 34 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`); the 14 monitoring tools are also free (account administration, not metered reads).
94
+ 98 tools: 62 reads and 36 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. Three of the reads are free account lookups (`twitter_account_me`, `twitter_account_payments`, `twitter_feedback_get`); the 14 monitoring tools and `twitter_feedback_send` are also free (account administration, not metered reads).
95
95
 
96
- Public reads (search, profiles, tweets, followers, likes) work with just your API key. The **account-only** reads (bookmarks, DMs, home timeline, followers-you-know) and **most 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, monitor/webhook delete) are annotated `destructiveHint: true` so MCP clients can prompt before running them. The **monitoring** tools (see below) are the one exception: they administer your twitterapis.com account, not an X session, so they need only your API key, no linked session and no inline credentials.
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 **most 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, monitor/webhook delete) are annotated `destructiveHint: true` so MCP clients can prompt before running them. The **monitoring** and **feedback** tools (see below) are the exception: they administer your twitterapis.com account, not an X session, so they need only your API key, no linked session and no inline credentials.
97
97
 
98
98
  ### Reads
99
99
 
@@ -204,6 +204,16 @@ Watch an X account for new posts and get them pushed to your own HTTPS endpoint,
204
204
  | `twitter_monitor_webhook_list` | List every webhook registered on your account |
205
205
  | `twitter_monitor_webhook_delete` | Soft-delete a webhook by id (irreversible from the caller's side) |
206
206
  | `twitter_monitor_webhook_test` | Send one signed test event to a webhook right now, synchronously |
207
+ | `twitter_monitor_webhook_redrive` | Replay deliveries that dead-lettered while your endpoint was down, oldest first |
208
+
209
+ ### Feedback _(report a bug or a gap to the twitterapis.com team without leaving your session; free)_
210
+
211
+ Modelled on Claude Code's own feedback tool. When a call fails in a way that is not your key, credits, session or a rate limit, when you ask for something no tool covers, or when a result is plainly wrong, the model can **draft** a report into a local queue (`~/.twitterapis/feedback-queue.json`, at most 10 drafts, override the directory with `TWITTERAPIS_FEEDBACK_DIR`). Nothing is sent until you ask to review the queue and name the drafts to send. Each report carries the last failing call's endpoint, status and request id, your client name and this package's version, so the team can act on it without a follow-up. Use `twitter_feedback_get` with the returned server id to see whether it was triaged, shipped or declined.
212
+
213
+ | Tool | What it does |
214
+ |---|---|
215
+ | `twitter_feedback_send` | `action: "draft"` (default) queues a report locally and sends nothing; `"list"` shows the queue; `"send"` posts only the drafts you name to `POST /feedback`; `"discard"` drops them |
216
+ | `twitter_feedback_get` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response |
207
217
 
208
218
  ### Session setup
209
219
 
@@ -212,6 +222,7 @@ Link an X account to your key once, so the account-only reads and write actions
212
222
  | Tool | What it does |
213
223
  |---|---|
214
224
  | `twitter_customer_session` | Register your x.com session cookies (`auth_token` + `ct0`) against your key |
225
+ | `twitter_customer_session_status` | Read back the registered session without changing it: resolved account, live/dead status, timestamps, and which egress tier a write would use. Never returns the cookies. Free |
215
226
  | `twitter_customer_session_delete` | Revoke that stored session, deleting your `auth_token` + `ct0` from the service. Idempotent and free |
216
227
  | `twitter_user_login` | Log in with `username` + `password` (+ `totp_secret` for 2FA); stores the session against your key. Returns a confirmation, never the cookies |
217
228
 
@@ -295,7 +306,7 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
295
306
 
296
307
  **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.
297
308
 
298
- **Is it read-only?** No. 60 read tools work with just your API key; 34 write actions (post, like, retweet, follow, DM, media upload, List create/add member/remove member, article create/edit/publish/delete, monitor/webhook create/update/delete) act as a linked X account or per-call inline credentials, except monitor/webhook CRUD, which is account administration and needs only your API key.
309
+ **Is it read-only?** No. 62 read tools work with just your API key; 36 write actions (post, like, retweet, follow, DM, media upload, List create/add member/remove member, article create/edit/publish/delete, monitor/webhook create/update/delete, feedback send) act as a linked X account or per-call inline credentials, except monitor/webhook CRUD and feedback, which are account administration and need only your API key.
299
310
 
300
311
  **Which clients are supported?** Claude Desktop, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
301
312
 
package/icon.png ADDED
Binary file
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.9.3",
4
+ "version": "0.9.6",
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",
@@ -16,7 +16,8 @@
16
16
  "src",
17
17
  "README.md",
18
18
  "LICENSE",
19
- "CHANGELOG.md"
19
+ "CHANGELOG.md",
20
+ "icon.png"
20
21
  ],
21
22
  "engines": {
22
23
  "node": ">=18"
@@ -27,7 +28,7 @@
27
28
  "build": "node scripts/gen-tools.mjs --write",
28
29
  "build:check": "node scripts/gen-tools.mjs --check",
29
30
  "openapi:refresh": "node scripts/openapi-refresh.mjs",
30
- "test": "node scripts/gen-tools.mjs --check && node test/gen-tools-endpoints.mjs && node test/catalog-identity.mjs && node test/tools.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/body-mode-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs",
31
+ "test": "node scripts/gen-tools.mjs --check && node test/gen-tools-endpoints.mjs && node test/catalog-identity.mjs && node test/tools.test.mjs && node test/feedback.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/body-mode-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs",
31
32
  "prepublishOnly": "npm test && node test/publish-provenance.mjs",
32
33
  "check:openapi-parity": "node test/openapi-parity.mjs",
33
34
  "check:body-mode-parity": "node test/body-mode-parity.mjs",
@@ -0,0 +1,237 @@
1
+ // src/feedback.js, the local half of twitter_feedback_send.
2
+ //
3
+ // Claude Code's own feedback tool works like this: the model drafts a report at
4
+ // a high-signal moment, the harness writes it to a LOCAL queue, and nothing
5
+ // leaves the machine until the user reviews the queue and approves. That
6
+ // consent step is what makes it safe to let a model draft freely. This module
7
+ // gives twitterapis.com customers the same loop inside whatever MCP client
8
+ // they already use: action "draft" appends to ~/.twitterapis/feedback-queue.json
9
+ // and sends nothing; "list" shows the drafts; "send" posts ONLY the ids the user
10
+ // named to POST /feedback (free, not metered); "discard" drops them.
11
+ //
12
+ // The tool DESCRIPTION in scripts/tools.overrides.mjs is the product: it names
13
+ // the trigger moments and the four-bullet format. This file enforces the same
14
+ // rules mechanically so a draft that reaches the server is well-formed, and it
15
+ // auto-attaches the evidence the server already holds (the last failing call,
16
+ // the client name from the MCP handshake, this package's version), so the model
17
+ // never has to type identifiers it might get wrong.
18
+ //
19
+ // STATE ON DISK, ON PURPOSE. The queue outlives the session: a user can review
20
+ // tomorrow what an agent drafted today. Writes are atomic (temp file + rename)
21
+ // because two MCP clients can share one home directory.
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+
28
+ export const QUEUE_CAP = 10;
29
+ export const TYPES = ["bug", "idea", "missing_capability"];
30
+ export const ACTIONS = ["draft", "list", "send", "discard"];
31
+ const MAX = { title: 120, details: 8000, area: 80 };
32
+ const EVIDENCE_MAX_BYTES = 4096;
33
+
34
+ /** Where drafts live. TWITTERAPIS_FEEDBACK_DIR overrides the default ~/.twitterapis. */
35
+ export function queuePath(env = process.env) {
36
+ const dir = env.TWITTERAPIS_FEEDBACK_DIR || join(homedir(), ".twitterapis");
37
+ return join(dir, "feedback-queue.json");
38
+ }
39
+
40
+ /** A draft this module can act on. Anything else in the file (a hand edit, an
41
+ * older shape, a null) is skipped rather than allowed to throw on every call. */
42
+ function isDraft(d) {
43
+ return d && typeof d === "object" && !Array.isArray(d)
44
+ && typeof d.id === "string" && typeof d.type === "string"
45
+ && typeof d.title === "string" && typeof d.details === "string";
46
+ }
47
+
48
+ export function readQueue(path) {
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ return Array.isArray(parsed?.drafts) ? parsed.drafts.filter(isDraft) : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function writeQueue(path, drafts) {
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
60
+ writeFileSync(tmp, JSON.stringify({ version: 1, drafts }, null, 2) + "\n");
61
+ renameSync(tmp, path);
62
+ }
63
+
64
+ // Two MCP servers can share one home directory, and temp+rename only keeps the
65
+ // file well-formed: without a lock a read-modify-write from each loses one
66
+ // side's drafts (measured 2026-09-04: two writers, five of ten drafts gone).
67
+ // mkdir is atomic on every platform node runs on, so a lock DIRECTORY is the
68
+ // mutex; a holder that died leaves it behind, so one older than STALE_MS is
69
+ // reclaimed.
70
+ const LOCK_STALE_MS = 10_000;
71
+ const LOCK_WAIT_MS = 3_000;
72
+ async function withLock(path, fn) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const lock = `${path}.lock`;
75
+ const deadline = Date.now() + LOCK_WAIT_MS;
76
+ for (;;) {
77
+ try {
78
+ mkdirSync(lock);
79
+ break;
80
+ } catch (err) {
81
+ if (err?.code !== "EEXIST") throw err;
82
+ try {
83
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { rmSync(lock, { recursive: true, force: true }); continue; }
84
+ } catch { /* vanished between checks; retry */ }
85
+ if (Date.now() > deadline) throw new Error(`feedback queue is locked by another process (${lock}); retry in a moment`);
86
+ await new Promise((r) => setTimeout(r, 25 + Math.floor(Math.random() * 50)));
87
+ }
88
+ }
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ rmSync(lock, { recursive: true, force: true });
93
+ }
94
+ }
95
+
96
+ /** Stable per-issue id: the same type + title redrafted replaces itself. */
97
+ export function draftId(type, title) {
98
+ return createHash("sha256").update(`${type}\n${title.trim().toLowerCase()}`).digest("hex").slice(0, 8);
99
+ }
100
+
101
+ const CLIENT_MAX = 120; // billing rejects longer; the model never types this field
102
+ function clientString(clientInfo, version) {
103
+ const name = clientInfo?.name ? `${clientInfo.name}${clientInfo.version ? `/${clientInfo.version}` : ""}` : "unknown-client";
104
+ return `${name} via @twitterapis/mcp@${version}`.slice(0, CLIENT_MAX);
105
+ }
106
+
107
+ // The last failing call is worth attaching only while it is plausibly the call
108
+ // the draft is about: recent, and not the feedback endpoint's own failure.
109
+ const LAST_ERROR_TTL_MS = 10 * 60 * 1000;
110
+ function usableLastError(last) {
111
+ if (!last || typeof last !== "object") return null;
112
+ if (typeof last.ts === "number" && Date.now() - last.ts > LAST_ERROR_TTL_MS) return null;
113
+ if (last.path === "/feedback" || (typeof last.path === "string" && last.path.startsWith("/feedback/"))) return null;
114
+ return last;
115
+ }
116
+
117
+ const text = (t, isError = false) => ({ isError, content: [{ type: "text", text: t }] });
118
+
119
+ function summarize(d) {
120
+ const first = d.details.split("\n")[0].slice(0, 140);
121
+ return `${d.id} [${d.type}] ${d.title}${d.area ? ` (${d.area})` : ""} drafted ${d.ts}\n ${first}`;
122
+ }
123
+
124
+ /**
125
+ * @param {object} deps
126
+ * @param {(path:string, args:object, method:string, jsonBody:boolean)=>Promise<object>} deps.callEndpoint
127
+ * @param {string} deps.version this package's version
128
+ * @param {() => ({name?:string, version?:string}|undefined)} [deps.getClientInfo]
129
+ * @param {() => (object|null)} [deps.getLastError] the last failing tool call, if any
130
+ * @param {object} [deps.env]
131
+ */
132
+ export function createFeedbackHandler({ callEndpoint, version, getClientInfo, getLastError, env = process.env }) {
133
+ return async function feedbackTool(args = {}) {
134
+ const action = args.action || "draft";
135
+ if (!ACTIONS.includes(action)) return text(`action must be one of ${ACTIONS.join(", ")}.`, true);
136
+ const path = queuePath(env);
137
+ try {
138
+ return await withLock(path, () => run(action, args, path));
139
+ } catch (err) {
140
+ return text(err?.message || String(err), true);
141
+ }
142
+ };
143
+
144
+ async function run(action, args, path) {
145
+ if (action === "draft") {
146
+ if (!TYPES.includes(args.type)) return text(`type is required for a draft and must be one of ${TYPES.join(", ")}.`, true);
147
+ const title = String(args.title ?? "").trim();
148
+ if (!title || title.length > MAX.title) return text(`title is required for a draft, at most ${MAX.title} characters.`, true);
149
+ const details = String(args.details ?? "").trim();
150
+ if (!details || details.length > MAX.details) return text(`details is required for a draft, at most ${MAX.details} characters. Use the four labelled bullets: What happened, What the user said, Repro, Evidence.`, true);
151
+ const area = args.area ? String(args.area).trim().slice(0, MAX.area) : undefined;
152
+
153
+ const client = clientString(getClientInfo?.(), version);
154
+ const evidence = { ...(args.evidence && typeof args.evidence === "object" && !Array.isArray(args.evidence) ? args.evidence : {}) };
155
+ const last = usableLastError(getLastError?.());
156
+ // Fill only what the model did not supply: its own evidence wins.
157
+ if (last) {
158
+ if (evidence.tool === undefined && last.tool) evidence.tool = last.tool;
159
+ if (evidence.endpoint === undefined && last.path) evidence.endpoint = last.path;
160
+ if (evidence.status === undefined && last.status) evidence.status = last.status;
161
+ if (evidence.request_id === undefined && last.requestId) evidence.request_id = last.requestId;
162
+ }
163
+ evidence.mcp_version = version;
164
+ evidence.client = client;
165
+ if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > EVIDENCE_MAX_BYTES) {
166
+ return text(`evidence must serialize to ${EVIDENCE_MAX_BYTES} bytes or fewer. Send identifiers (tool, endpoint, status, request id), never payloads.`, true);
167
+ }
168
+
169
+ const drafts = readQueue(path);
170
+ const draft = { id: draftId(args.type, title), ts: new Date().toISOString(), type: args.type, title, details, area, evidence, client };
171
+ const idx = drafts.findIndex((d) => d.id === draft.id);
172
+ if (idx >= 0) {
173
+ drafts[idx] = draft;
174
+ } else {
175
+ if (drafts.length >= QUEUE_CAP) {
176
+ return text(`The local feedback queue already holds ${QUEUE_CAP} drafts. Ask the user to review them (action "list", then "send" or "discard") before drafting more.`, true);
177
+ }
178
+ drafts.push(draft);
179
+ }
180
+ writeQueue(path, drafts);
181
+ return text(
182
+ `Queued locally as draft ${draft.id}${idx >= 0 ? " (replaced an earlier draft with the same title)" : ""}. ${drafts.length} draft(s) pending in ${path}. ` +
183
+ `Nothing was sent and nothing needs to be said to the user right now. When the user asks to review or send feedback, call this tool with action "list", ` +
184
+ `then action "send" with only the ids the user names, or "discard".`,
185
+ );
186
+ }
187
+
188
+ const drafts = readQueue(path);
189
+
190
+ if (action === "list") {
191
+ if (drafts.length === 0) return text(`No feedback drafts pending (${path}).`);
192
+ return text(
193
+ `${drafts.length} feedback draft(s) pending in ${path}. Show these to the user and send only the ids they name:\n\n` +
194
+ drafts.map(summarize).join("\n"),
195
+ );
196
+ }
197
+
198
+ const ids = [...new Set((Array.isArray(args.ids) ? args.ids : []).map(String))];
199
+ if (ids.length === 0) return text(`action "${action}" needs ids: the draft ids the user named (from action "list").`, true);
200
+ const unknown = ids.filter((id) => !drafts.some((d) => d.id === id));
201
+ if (unknown.length) return text(`Unknown draft id(s): ${unknown.join(", ")}. Run action "list" to see the current ids.`, true);
202
+
203
+ if (action === "discard") {
204
+ const kept = drafts.filter((d) => !ids.includes(d.id));
205
+ writeQueue(path, kept);
206
+ return text(`Discarded ${ids.length} draft(s): ${ids.join(", ")}. ${kept.length} still pending.`);
207
+ }
208
+
209
+ // send
210
+ const sent = [];
211
+ const failed = [];
212
+ let remaining = drafts;
213
+ for (const id of ids) {
214
+ const d = drafts.find((x) => x.id === id);
215
+ const body = { type: d.type, title: d.title, details: d.details, evidence: d.evidence, client: d.client };
216
+ if (d.area) body.area = d.area;
217
+ const res = await callEndpoint("/feedback", body, "POST", true);
218
+ const out = res?.content?.[0]?.text ?? "";
219
+ if (res?.isError) {
220
+ failed.push(`${id}: ${out.slice(0, 300)}`);
221
+ continue;
222
+ }
223
+ let serverId = null;
224
+ try { serverId = JSON.parse(out).id ?? null; } catch { /* body was not JSON; keep null */ }
225
+ sent.push(`${id} -> ${serverId ?? "sent"}`);
226
+ remaining = remaining.filter((x) => x.id !== id);
227
+ // Persist after EACH success, so a process that dies mid-loop cannot
228
+ // re-send a report the server already holds.
229
+ writeQueue(path, remaining);
230
+ }
231
+ const lines = [];
232
+ if (sent.length) lines.push(`Sent ${sent.length} report(s) to twitterapis.com (free, not metered):\n ${sent.join("\n ")}\nCheck one later with twitter_feedback_get using the server id.`);
233
+ if (failed.length) lines.push(`${failed.length} draft(s) stayed in the queue because the send failed:\n ${failed.join("\n ")}`);
234
+ lines.push(`${remaining.length} draft(s) still pending.`);
235
+ return text(lines.join("\n\n"), sent.length === 0);
236
+ }
237
+ }
package/src/index.js CHANGED
@@ -25,12 +25,36 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
25
25
  // outbound user-agent both still advertised 0.3.0.
26
26
  const VERSION = createRequire(import.meta.url)("../package.json").version;
27
27
  import { TOOLS, buildQuery, resolvePathParams, MissingPathParamError } from "./tools.js";
28
+ import { createFeedbackHandler } from "./feedback.js";
28
29
 
29
30
  const API_KEY = process.env.TWITTERAPIS_KEY;
30
31
  const BASE_URL = (
31
32
  process.env.TWITTERAPIS_BASE_URL || "https://api.twitterapis.com"
32
33
  ).replace(/\/+$/, "");
33
- const REQUEST_TIMEOUT_MS = Number(process.env.TWITTERAPIS_TIMEOUT_MS || 30000);
34
+
35
+ const DEFAULT_TIMEOUT_MS = 30000;
36
+ // A malformed TWITTERAPIS_TIMEOUT_MS (non-numeric, or <= 0) used to reach
37
+ // setTimeout() unvalidated. Number("30000ms") and Number("60,000") are both
38
+ // NaN, and Node clamps a NaN or sub-1 delay to ~1ms (verified directly:
39
+ // `setTimeout(fn, NaN)` fires in under 1ms), so every tool call aborted
40
+ // almost immediately with "Request failed: timed out after NaNms" -- which
41
+ // reads as a live outage, not the config typo it actually is. An explicit 0
42
+ // or negative value has the same effect with no typo needed at all. Fall
43
+ // back to the documented default on anything that is not a finite, positive
44
+ // number, and say so loudly rather than silently eating every call.
45
+ let REQUEST_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
46
+ const rawTimeoutEnv = process.env.TWITTERAPIS_TIMEOUT_MS;
47
+ if (rawTimeoutEnv) {
48
+ const parsed = Number(rawTimeoutEnv);
49
+ if (Number.isFinite(parsed) && parsed > 0) {
50
+ REQUEST_TIMEOUT_MS = parsed;
51
+ } else {
52
+ console.error(
53
+ `[twitterapis-mcp] TWITTERAPIS_TIMEOUT_MS="${rawTimeoutEnv}" is not a positive number; ` +
54
+ `falling back to the default ${DEFAULT_TIMEOUT_MS}ms instead of timing out every call immediately.`,
55
+ );
56
+ }
57
+ }
34
58
 
35
59
  // Lazy validation, not exit-on-boot: an MCP registry scanner (Smithery, Glama,
36
60
  // the official registry, Claude Connectors) connects the stdio transport with
@@ -58,6 +82,11 @@ if (!API_KEY) {
58
82
  // substitute into the URL template) and callEndpoint splices them into path
59
83
  // before building the query string or body, so a pathParams arg never leaks
60
84
  // into either.
85
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
86
+ // status and request id without the model retyping them. Set in callEndpoint's
87
+ // error branch; the tool name is added by the registration wrapper below.
88
+ let lastError = null;
89
+
61
90
  async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
62
91
  if (!API_KEY) {
63
92
  return {
@@ -145,11 +174,31 @@ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathPa
145
174
  : res.status >= 500
146
175
  ? " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)"
147
176
  : "";
148
- return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}` }] };
177
+ lastError = {
178
+ path: resolvedPath,
179
+ method,
180
+ status: res.status,
181
+ requestId: res.headers.get("x-request-id") || undefined,
182
+ ts: Date.now(),
183
+ };
184
+ // Credential, credit, session, rate-limit and not-found failures are the
185
+ // caller's situation (a 404 is almost always a wrong id), not a product
186
+ // defect; everything else may be one, and the model reads error bodies
187
+ // closely, so the pointer lives here.
188
+ const feedbackHint =
189
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
190
+ ? ""
191
+ : " If this blocked the user's task and looks like a defect or a missing capability, draft a report with twitter_feedback_send (queued locally until the user reviews it).";
192
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
149
193
  }
194
+ // A success clears the record so a later draft never inherits an old
195
+ // failure's endpoint or request id (review 2026-09-04: a delete's draft
196
+ // carried the previous update's 404).
197
+ lastError = null;
150
198
  return { content: [{ type: "text", text: body }] };
151
199
  } catch (err) {
152
200
  const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
201
+ lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
153
202
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
154
203
  } finally {
155
204
  clearTimeout(timer);
@@ -157,7 +206,27 @@ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathPa
157
206
  }
158
207
 
159
208
  // ── MCP server ───────────────────────────────────────────────────────────────
160
- const server = new McpServer({ name: "twitterapis", version: VERSION });
209
+ // Standing instructions the client hands its model alongside the tool list.
210
+ // This is the trigger list for feedback, in the place a model actually reads.
211
+ const INSTRUCTIONS =
212
+ "twitterapis.com MCP server. Read tools cost credits per call (most $0.0008); account, monitoring and feedback tools are free. " +
213
+ "If a twitterapis tool call fails with an error other than 401/402/409/429 and the user has to work around it, if the user asks for something no twitterapis tool covers, " +
214
+ "if a documented field comes back empty or wrong, or if the user is clearly frustrated with a result, draft a report with twitter_feedback_send (action \"draft\"). " +
215
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\".";
216
+
217
+ const server = new McpServer({ name: "twitterapis", version: VERSION }, { instructions: INSTRUCTIONS });
218
+
219
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
220
+ // catalog uses and this map lacks is a boot-time failure, never a silent
221
+ // passthrough to the API with the local args attached.
222
+ const LOCAL_HANDLERS = {
223
+ feedback: createFeedbackHandler({
224
+ callEndpoint,
225
+ version: VERSION,
226
+ getClientInfo: () => server.server.getClientVersion(),
227
+ getLastError: () => lastError,
228
+ }),
229
+ };
161
230
 
162
231
  for (const tool of TOOLS) {
163
232
  const method = tool.method || "GET";
@@ -169,10 +238,27 @@ for (const tool of TOOLS) {
169
238
  destructiveHint: Boolean(tool.destructive),
170
239
  openWorldHint: true,
171
240
  };
241
+ let handler;
242
+ if (tool.local) {
243
+ handler = LOCAL_HANDLERS[tool.local];
244
+ if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
245
+ } else {
246
+ handler = async (args) => {
247
+ const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
248
+ if (result?.isError && lastError) {
249
+ let resolved = null;
250
+ try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
251
+ // Name the tool only when BOTH method and path match the recorded
252
+ // failure; two tools share /monitor/{id} (POST update, DELETE remove).
253
+ if (resolved === lastError.path && method === lastError.method) lastError.tool = tool.name;
254
+ }
255
+ return result;
256
+ };
257
+ }
172
258
  server.registerTool(
173
259
  tool.name,
174
260
  { description: tool.description, inputSchema: tool.shape, annotations },
175
- async (args) => callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []),
261
+ handler,
176
262
  );
177
263
  }
178
264
 
package/src/tools.js CHANGED
@@ -8,12 +8,12 @@
8
8
  // file in memory and fails if it does not match what is committed, so a hand edit
9
9
  // here is caught rather than shipped.
10
10
  //
11
- // Catalog: 94 tools (60 reads, 34 writes).
11
+ // Catalog: 98 tools (62 reads, 36 writes).
12
12
  //
13
13
  // Each tool maps 1:1 to a REST endpoint at https://api.twitterapis.com. Tool arg
14
14
  // names map 1:1 to endpoint query params (every endpoint, including the POST
15
15
  // write actions, reads its params from the query string), except the per-call
16
- // inline credentials, which travel as x-* request headers, the 9
16
+ // inline credentials, which travel as x-* request headers, the 11
17
17
  // jsonBody tools, whose fields travel in a JSON request body, and any arg listed
18
18
  // in pathParams, which is substituted into the URL path (e.g. {id}) instead. A
19
19
  // tool with `method: "POST"` or `method: "DELETE"` is a write that acts on
@@ -22,6 +22,10 @@
22
22
  //
23
23
  // write:true -> action mutates account/Twitter state (readOnlyHint:false)
24
24
  // destructive:true -> action removes/reverses state (delete, un-follow/like/RT/bookmark)
25
+ // local:"<name>" -> src/index.js dispatches the call to a handler in this
26
+ // package instead of a plain passthrough (feedback's draft
27
+ // queue); args flagged local:true in the overrides are
28
+ // consumed there and never reach the API
25
29
  // pathParams -> arg names substituted into the URL template, not sent as
26
30
  // query-string or body fields (e.g. ["id"] for /monitor/{id})
27
31
  import { z } from "zod";
@@ -40,7 +44,7 @@ export const TOOLS = [
40
44
  "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.",
41
45
  ),
42
46
  count: z.number().int().min(1).max(200).optional().describe(
43
- "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.",
47
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
44
48
  ),
45
49
  cursor: z.string().optional().describe(
46
50
  "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.",
@@ -57,7 +61,7 @@ export const TOOLS = [
57
61
  "Name, keyword, or topic to search accounts for. Examples: 'OpenAI', 'AI researcher', 'tech founder'.",
58
62
  ),
59
63
  count: z.number().int().min(1).max(200).optional().describe(
60
- "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.",
64
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
61
65
  ),
62
66
  cursor: z.string().optional().describe(
63
67
  "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.",
@@ -127,7 +131,7 @@ export const TOOLS = [
127
131
  "Optional team/sub-group name to filter affiliates by, when the org exposes named teams.",
128
132
  ),
129
133
  count: z.number().int().min(1).max(200).optional().describe(
130
- "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.",
134
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
131
135
  ),
132
136
  cursor: z.string().optional().describe(
133
137
  "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.",
@@ -161,7 +165,7 @@ export const TOOLS = [
161
165
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
162
166
  ),
163
167
  count: z.number().int().min(1).max(200).optional().describe(
164
- "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.",
168
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
165
169
  ),
166
170
  cursor: z.string().optional().describe(
167
171
  "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.",
@@ -181,7 +185,7 @@ export const TOOLS = [
181
185
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
182
186
  ),
183
187
  count: z.number().int().min(1).max(200).optional().describe(
184
- "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.",
188
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
185
189
  ),
186
190
  cursor: z.string().optional().describe(
187
191
  "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.",
@@ -215,7 +219,7 @@ export const TOOLS = [
215
219
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
216
220
  ),
217
221
  count: z.number().int().min(1).max(200).optional().describe(
218
- "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.",
222
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
219
223
  ),
220
224
  cursor: z.string().optional().describe(
221
225
  "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.",
@@ -232,7 +236,7 @@ export const TOOLS = [
232
236
  "Twitter/X handle WITHOUT the leading @ of the user to find mentions for (e.g. 'openai' to find tweets mentioning @openai).",
233
237
  ),
234
238
  count: z.number().int().min(1).max(200).optional().describe(
235
- "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.",
239
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
236
240
  ),
237
241
  cursor: z.string().optional().describe(
238
242
  "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.",
@@ -249,7 +253,7 @@ export const TOOLS = [
249
253
  "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.",
250
254
  ),
251
255
  count: z.number().int().min(1).max(200).optional().describe(
252
- "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.",
256
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
253
257
  ),
254
258
  cursor: z.string().optional().describe(
255
259
  "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.",
@@ -269,7 +273,7 @@ export const TOOLS = [
269
273
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
270
274
  ),
271
275
  count: z.number().int().min(1).max(200).optional().describe(
272
- "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.",
276
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
273
277
  ),
274
278
  cursor: z.string().optional().describe(
275
279
  "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.",
@@ -289,7 +293,7 @@ export const TOOLS = [
289
293
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
290
294
  ),
291
295
  count: z.number().int().min(1).max(200).optional().describe(
292
- "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.",
296
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
293
297
  ),
294
298
  cursor: z.string().optional().describe(
295
299
  "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.",
@@ -309,7 +313,7 @@ export const TOOLS = [
309
313
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
310
314
  ),
311
315
  count: z.number().int().min(1).max(200).optional().describe(
312
- "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.",
316
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
313
317
  ),
314
318
  cursor: z.string().optional().describe(
315
319
  "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.",
@@ -329,7 +333,7 @@ export const TOOLS = [
329
333
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
330
334
  ),
331
335
  count: z.number().int().min(1).max(200).optional().describe(
332
- "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.",
336
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
333
337
  ),
334
338
  cursor: z.string().optional().describe(
335
339
  "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.",
@@ -349,7 +353,7 @@ export const TOOLS = [
349
353
  "Numeric Twitter/X user id (e.g. \"44196397\"). Provide exactly one of username or user_id.",
350
354
  ),
351
355
  count: z.number().int().min(1).max(200).optional().describe(
352
- "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.",
356
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
353
357
  ),
354
358
  cursor: z.string().optional().describe(
355
359
  "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.",
@@ -366,7 +370,7 @@ export const TOOLS = [
366
370
  "Numeric user id of the target account to compute shared followers against.",
367
371
  ),
368
372
  count: z.number().int().min(1).max(200).optional().describe(
369
- "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.",
373
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
370
374
  ),
371
375
  cursor: z.string().optional().describe(
372
376
  "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.",
@@ -443,7 +447,7 @@ export const TOOLS = [
443
447
  "Full tweet URL, e.g. \"https://x.com/elonmusk/status/1789012345678901234\". Provide exactly one of id or url.",
444
448
  ),
445
449
  count: z.number().int().min(1).max(200).optional().describe(
446
- "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.",
450
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
447
451
  ),
448
452
  cursor: z.string().optional().describe(
449
453
  "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.",
@@ -486,7 +490,7 @@ export const TOOLS = [
486
490
  "Numeric Twitter/X List id. Found in the list URL: x.com/i/lists/<list_id>.",
487
491
  ),
488
492
  count: z.number().int().min(1).max(200).optional().describe(
489
- "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.",
493
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
490
494
  ),
491
495
  cursor: z.string().optional().describe(
492
496
  "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.",
@@ -775,6 +779,52 @@ export const TOOLS = [
775
779
  "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).",
776
780
  shape: {},
777
781
  },
782
+ {
783
+ name: "twitter_feedback_send",
784
+ path: "/feedback",
785
+ method: "POST",
786
+ write: true,
787
+ jsonBody: true,
788
+ local: "feedback",
789
+ localArgs: ["action","ids"],
790
+ description:
791
+ "Report a product problem or gap in twitterapis.com to its team from inside this session, the way Claude Code's own feedback tool works: a report is DRAFTED to a local queue first (action \"draft\", the default) and SENT only after the user reviews it. Drafting sends nothing, needs no confirmation, and should not be announced mid-task. WHEN TO DRAFT, only at high-signal moments: a twitterapis tool call failed with an error that was not a missing key (401), credits (402), no linked session (409) or a rate limit (429), and the user had to work around it; the user asked for something no twitterapis tool covers; a documented field came back empty or wrong; the user was clearly frustrated with a result. One draft per distinct issue, never twice for the same one. FORMAT for details, four labelled bullets in this order: 'What happened:' observed vs expected, exact error text if short. 'What the user said:' quoted verbatim, or 'user did not comment'. 'Repro:' the minimal call that reproduces it. 'Evidence:' tool name, endpoint, HTTP status, request id (the last failing call is attached automatically where you leave a gap). Facts only: no guessing, no API keys or secrets, no personal names. REVIEW: when the user asks to see or send feedback, call action \"list\", then action \"send\" with ONLY the draft ids the user named in their own message, or action \"discard\". Sending posts each draft to POST /feedback (free) and returns a server id that twitter_feedback_get can check later.",
792
+ shape: {
793
+ action: z.enum(["draft","list","send","discard"]).optional().describe(
794
+ "What to do. \"draft\" (default) queues a new report locally and sends nothing. \"list\" shows the pending drafts with their ids. \"send\" posts the drafts named in ids to twitterapis.com; use it only for ids the user named. \"discard\" drops the drafts named in ids.",
795
+ ),
796
+ type: z.enum(["bug","idea","missing_capability"]).optional().describe(
797
+ "Required for a draft. \"bug\": a tool or endpoint misbehaved. \"idea\": a change that would have made the task easier. \"missing_capability\": the user needed something no tool provides.",
798
+ ),
799
+ title: z.string().optional().describe(
800
+ "Required for a draft. One specific line, at most 120 characters, naming the tool or endpoint and the defect, e.g. \"twitter_tweet_thread returns 502 when the root tweet is deleted\".",
801
+ ),
802
+ details: z.string().optional().describe(
803
+ "Required for a draft. At most 8000 characters, four labelled bullets in order: What happened, What the user said (verbatim), Repro, Evidence.",
804
+ ),
805
+ area: z.string().optional().describe(
806
+ "Optional. The endpoint or feature the report is about, e.g. \"tweet/thread\" or \"monitoring\". At most 80 characters.",
807
+ ),
808
+ evidence: z.record(z.string(), z.unknown()).optional().describe(
809
+ "Optional identifiers only, never payloads: {tool, endpoint, status, request_id}. Whatever you leave out is filled from the last failing call in this session; mcp_version and client are always attached.",
810
+ ),
811
+ ids: z.array(z.string()).optional().describe(
812
+ "For action \"send\" or \"discard\": the draft ids to act on, exactly as shown by action \"list\" and named by the user.",
813
+ ),
814
+ },
815
+ },
816
+ {
817
+ name: "twitter_feedback_get",
818
+ path: "/feedback/{id}",
819
+ pathParams: ["id"],
820
+ description:
821
+ "Check the status of a feedback report this account sent earlier (the server id returned by twitter_feedback_send action \"send\"): status new, triaged, shipped or declined, the team's response text if any, and updated_at, which moves only when the team acts on it. Free per call. 404 if the id is not on this account.",
822
+ shape: {
823
+ id: z.string().describe(
824
+ "The server id of a sent report, as returned by twitter_feedback_send action \"send\" (a UUID). Not a local draft id.",
825
+ ),
826
+ },
827
+ },
778
828
  {
779
829
  name: "twitter_home_timeline",
780
830
  path: "/twitter/user/home_timeline",
@@ -782,7 +832,7 @@ export const TOOLS = [
782
832
  "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.",
783
833
  shape: {
784
834
  count: z.number().int().min(1).max(200).optional().describe(
785
- "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.",
835
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
786
836
  ),
787
837
  cursor: z.string().optional().describe(
788
838
  "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.",
@@ -808,7 +858,7 @@ export const TOOLS = [
808
858
  "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.",
809
859
  shape: {
810
860
  count: z.number().int().min(1).max(200).optional().describe(
811
- "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.",
861
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
812
862
  ),
813
863
  cursor: z.string().optional().describe(
814
864
  "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.",
@@ -834,7 +884,7 @@ export const TOOLS = [
834
884
  "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.",
835
885
  shape: {
836
886
  count: z.number().int().min(1).max(200).optional().describe(
837
- "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.",
887
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
838
888
  ),
839
889
  cursor: z.string().optional().describe(
840
890
  "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.",
@@ -860,7 +910,7 @@ export const TOOLS = [
860
910
  "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.",
861
911
  shape: {
862
912
  count: z.number().int().min(1).max(200).optional().describe(
863
- "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.",
913
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
864
914
  ),
865
915
  cursor: z.string().optional().describe(
866
916
  "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.",
@@ -889,7 +939,7 @@ export const TOOLS = [
889
939
  "Search terms to match against your bookmarked tweets' text.",
890
940
  ),
891
941
  count: z.number().int().min(1).max(200).optional().describe(
892
- "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.",
942
+ "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",
893
943
  ),
894
944
  cursor: z.string().optional().describe(
895
945
  "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.",
@@ -1421,6 +1471,13 @@ export const TOOLS = [
1421
1471
  ),
1422
1472
  },
1423
1473
  },
1474
+ {
1475
+ name: "twitter_customer_session_status",
1476
+ path: "/twitter/customer/session/status",
1477
+ description:
1478
+ "Read back the X account session you registered with twitter_customer_session, without changing it. Returns registered (false if you never registered one), the resolved username and twitter_user_id the session actually maps to, status ('ok', or 'dead' once X has rejected the cookies), created_at, updated_at, last_used_at, and an egress block: source (one of session, sticky_residential, pool_residential, direct), customer_proxy_in_use (true when the proxy_url you registered is the one your writes leave from), and a note explaining that tier. Never returns auth_token, ct0, or any proxy URL. Use it to answer 'am I posting as the account I think I am', 'has my session expired', and 'is the proxy I supplied actually being used' without opening a support ticket. Free, and scoped to your own API key by construction: it takes no account identifier of any kind, so it cannot read another key's session.",
1479
+ shape: {},
1480
+ },
1424
1481
  {
1425
1482
  name: "twitter_customer_session_delete",
1426
1483
  path: "/twitter/customer/session/delete",
@@ -1780,6 +1837,9 @@ export const TOOLS = [
1780
1837
  webhook_ids: z.string().optional().describe(
1781
1838
  "Optional. Comma-separated webhook id(s) from twitter_monitor_webhook_create to restrict this monitor's deliveries to. Omit to deliver to every active webhook on the account (the default).",
1782
1839
  ),
1840
+ include_replies: z.string().optional().describe(
1841
+ "Optional boolean. true delivers the account's replies as well as its own posts, which is the default and what every monitor has always done; false holds replies back and delivers only the account's own posts. Must be a real boolean: the string \"false\" and the number 0 are rejected with a 400 rather than coerced, because coercing them would quietly give you the opposite of what you typed, and the wrong answer here is invisible since it looks exactly like the account not having posted.",
1842
+ ),
1783
1843
  domain_filter: z.string().optional().describe(
1784
1844
  "Optional. A bare hostname ('example.com') or a full URL ('https://example.com/blog') to restrict delivery to only the new posts that link to that host or a subdomain of it (e.g. 'example.com' matches both example.com and blog.example.com). Normalized server-side: lowercased, scheme/path/query/fragment/leading www./trailing :port stripped. Omit for no filter, the default (deliver every new post). Rejected with a 400 if what remains after normalization is not a valid hostname shape. A post with no matching link is filtered out of delivery, never silently dropped: it still advances the monitor's cursor and counts toward the account's tweets_domain_filtered health metric.",
1785
1845
  ),
@@ -1814,6 +1874,9 @@ export const TOOLS = [
1814
1874
  domain_filter: z.string().nullable().optional().describe(
1815
1875
  "Optional. A bare hostname or full URL to restrict delivery to, same shape and normalization as twitter_monitor_create's domain_filter. Pass an empty string (or null) to clear an existing filter back to 'deliver every new post'. Omit entirely to leave the current filter unchanged. Rejected with a 400 if a non-empty value does not normalize to a valid hostname.",
1816
1876
  ),
1877
+ include_replies: z.string().optional().describe(
1878
+ "Optional boolean. true delivers the account's replies as well as its own posts, false holds replies back and delivers only its own posts. Omit the field entirely to leave it unchanged. Same boolean-only validation as twitter_monitor_create: a non-boolean is a 400 rather than a coercion.",
1879
+ ),
1817
1880
  },
1818
1881
  },
1819
1882
  {
@@ -1947,6 +2010,27 @@ export const TOOLS = [
1947
2010
  ),
1948
2011
  },
1949
2012
  },
2013
+ {
2014
+ name: "twitter_monitor_webhook_redrive",
2015
+ path: "/twitter/webhook/{id}/redrive",
2016
+ method: "POST",
2017
+ write: true,
2018
+ jsonBody: true,
2019
+ pathParams: ["id"],
2020
+ description:
2021
+ "Replay deliveries that dead-lettered while your endpoint was down. A delivery is dead-lettered after it fails all 8 attempts across 21 minutes, so an outage longer than that window loses those events; this re-queues them with a full retry budget, oldest first. Bounded by default so a recovered endpoint is not flooded: max_age_hours defaults to 24 and limit to 100. Returns requeued and skipped_permanent. A delivery that died for a permanent reason, a 410 Gone, a deleted webhook, or a URL egress refused, is not replayed, because it would fail the same way and spend the budget again. Replayed events carry the same signature and payload as the original, so make your handler idempotent on the event id if a duplicate would matter. Returns 409 if the webhook is disabled, which happens after your endpoint answers 410 Gone: re-register it first. Free per call.",
2022
+ shape: {
2023
+ id: z.string().describe(
2024
+ "The webhook's id, from twitter_monitor_webhook_create or twitter_monitor_webhook_list.",
2025
+ ),
2026
+ max_age_hours: z.number().int().optional().describe(
2027
+ "Optional. How far back to look for dead-lettered deliveries, 1 to 168 hours. Defaults to 24.",
2028
+ ),
2029
+ limit: z.number().int().optional().describe(
2030
+ "Optional. Most deliveries to replay in one call, 1 to 1000, oldest first. Defaults to 100.",
2031
+ ),
2032
+ },
2033
+ },
1950
2034
  ];
1951
2035
 
1952
2036
  // The query-string builder and the path-param substitution helper are