@twitterapis/mcp 0.9.5 → 0.9.7

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.7 (2026-09-04)
4
+
5
+ ### Fixed
6
+
7
+ - **`twitter_feedback_send` no longer holds the local queue lock while it talks to the API.** The lock's stale threshold is 10s and a request may take up to 30s, so a send that held it let a second MCP process reclaim the lock, write its draft, and then lose that draft to the sender's pre-send snapshot. Drafts to send are now picked under the lock, posted with it released, and after each success the lock is re-taken, the queue re-read and exactly that draft removed. A draft added by another process mid-send survives; a crash mid-batch still never resends a posted report. If the per-success removal itself cannot take the lock, the report is still shown as posted with its server id and the caller is told to discard that draft rather than send it again.
8
+
9
+ ### Added
10
+
11
+ - **`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.
12
+ - **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())`.
13
+
3
14
  ## 0.9.5 (2026-08-31)
4
15
 
5
16
  ### Added
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
- 96 tools: 61 reads and 35 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
 
@@ -206,6 +206,15 @@ Watch an X account for new posts and get them pushed to your own HTTPS endpoint,
206
206
  | `twitter_monitor_webhook_test` | Send one signed test event to a webhook right now, synchronously |
207
207
  | `twitter_monitor_webhook_redrive` | Replay deliveries that dead-lettered while your endpoint was down, oldest first |
208
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 |
217
+
209
218
  ### Session setup
210
219
 
211
220
  Link an X account to your key once, so the account-only reads and write actions act as it (or pass per-call `auth_token`/`ct0` instead).
@@ -297,7 +306,7 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
297
306
 
298
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.
299
308
 
300
- **Is it read-only?** No. 61 read tools work with just your API key; 35 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.
301
310
 
302
311
  **Which clients are supported?** Claude Desktop, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
303
312
 
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.5",
4
+ "version": "0.9.7",
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",
@@ -28,7 +28,7 @@
28
28
  "build": "node scripts/gen-tools.mjs --write",
29
29
  "build:check": "node scripts/gen-tools.mjs --check",
30
30
  "openapi:refresh": "node scripts/openapi-refresh.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/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",
32
32
  "prepublishOnly": "npm test && node test/publish-provenance.mjs",
33
33
  "check:openapi-parity": "node test/openapi-parity.mjs",
34
34
  "check:body-mode-parity": "node test/body-mode-parity.mjs",
@@ -0,0 +1,268 @@
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
+ if (action === "send") return await send(args, path);
139
+ return await withLock(path, () => run(action, args, path));
140
+ } catch (err) {
141
+ return text(err?.message || String(err), true);
142
+ }
143
+ };
144
+
145
+ // send: NEVER hold the lock across a network call. LOCK_STALE_MS is 10s and a
146
+ // request may take up to the client timeout (30s), so a send that held the lock
147
+ // let a concurrent draft reclaim it as stale and then overwrote that draft with
148
+ // the sender's pre-send snapshot (measured 2026-09-04: A sends with an 11.5s
149
+ // upstream, B drafts at t=8.5s, B's draft is gone). Now: pick the drafts under
150
+ // the lock, release, post each without it, and after every success re-take the
151
+ // lock, RE-READ the queue and remove exactly that id. A draft added meanwhile
152
+ // survives; a crash mid-batch still never resends a report the server holds.
153
+ async function send(args, path) {
154
+ const ids = [...new Set((Array.isArray(args.ids) ? args.ids : []).map(String))];
155
+ if (ids.length === 0) return text(`action "send" needs ids: the draft ids the user named (from action "list").`, true);
156
+ const picked = await withLock(path, () => {
157
+ const drafts = readQueue(path);
158
+ const unknown = ids.filter((id) => !drafts.some((d) => d.id === id));
159
+ if (unknown.length) return { unknown };
160
+ return { drafts: ids.map((id) => drafts.find((x) => x.id === id)) };
161
+ });
162
+ if (picked.unknown) return text(`Unknown draft id(s): ${picked.unknown.join(", ")}. Run action "list" to see the current ids.`, true);
163
+
164
+ const sent = [];
165
+ const failed = [];
166
+ const stuck = [];
167
+ for (const d of picked.drafts) {
168
+ const body = { type: d.type, title: d.title, details: d.details, evidence: d.evidence, client: d.client };
169
+ if (d.area) body.area = d.area;
170
+ const res = await callEndpoint("/feedback", body, "POST", true);
171
+ const out = res?.content?.[0]?.text ?? "";
172
+ if (res?.isError) {
173
+ failed.push(`${d.id}: ${out.slice(0, 300)}`);
174
+ continue;
175
+ }
176
+ let serverId = null;
177
+ try { serverId = JSON.parse(out).id ?? null; } catch { /* body was not JSON; keep null */ }
178
+ sent.push(`${d.id} -> ${serverId ?? "sent"}`);
179
+ // Persist after EACH success against the CURRENT queue, so a process that
180
+ // dies mid-loop cannot re-send, and a draft another process added while
181
+ // this one was on the network is kept. The server already holds this
182
+ // report, so a lock that cannot be re-taken (a foreign holder past the
183
+ // wait) must NOT turn into an error that hides the server id and leaves
184
+ // the draft re-sendable: report it as posted and name the draft to discard.
185
+ try {
186
+ await withLock(path, () => writeQueue(path, readQueue(path).filter((x) => x.id !== d.id)));
187
+ } catch (err) {
188
+ stuck.push(`${d.id} (server id ${serverId ?? "unknown"}): ${err?.message || String(err)}`);
189
+ }
190
+ }
191
+ let remaining;
192
+ try { remaining = await withLock(path, () => readQueue(path).length); } catch { remaining = readQueue(path).length; }
193
+ const lines = [];
194
+ 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.`);
195
+ if (failed.length) lines.push(`${failed.length} draft(s) stayed in the queue because the send failed:\n ${failed.join("\n ")}`);
196
+ if (stuck.length) lines.push(`${stuck.length} report(s) WERE posted but the local draft could not be removed (the queue was locked). Do not send these ids again; remove them with action "discard":\n ${stuck.join("\n ")}`);
197
+ lines.push(`${remaining} draft(s) still pending.`);
198
+ return text(lines.join("\n\n"), sent.length === 0);
199
+ }
200
+
201
+ async function run(action, args, path) {
202
+ if (action === "draft") {
203
+ if (!TYPES.includes(args.type)) return text(`type is required for a draft and must be one of ${TYPES.join(", ")}.`, true);
204
+ const title = String(args.title ?? "").trim();
205
+ if (!title || title.length > MAX.title) return text(`title is required for a draft, at most ${MAX.title} characters.`, true);
206
+ const details = String(args.details ?? "").trim();
207
+ 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);
208
+ const area = args.area ? String(args.area).trim().slice(0, MAX.area) : undefined;
209
+
210
+ const client = clientString(getClientInfo?.(), version);
211
+ const evidence = { ...(args.evidence && typeof args.evidence === "object" && !Array.isArray(args.evidence) ? args.evidence : {}) };
212
+ const last = usableLastError(getLastError?.());
213
+ // Fill only what the model did not supply: its own evidence wins.
214
+ if (last) {
215
+ if (evidence.tool === undefined && last.tool) evidence.tool = last.tool;
216
+ if (evidence.endpoint === undefined && last.path) evidence.endpoint = last.path;
217
+ if (evidence.status === undefined && last.status) evidence.status = last.status;
218
+ if (evidence.request_id === undefined && last.requestId) evidence.request_id = last.requestId;
219
+ }
220
+ evidence.mcp_version = version;
221
+ evidence.client = client;
222
+ if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > EVIDENCE_MAX_BYTES) {
223
+ return text(`evidence must serialize to ${EVIDENCE_MAX_BYTES} bytes or fewer. Send identifiers (tool, endpoint, status, request id), never payloads.`, true);
224
+ }
225
+
226
+ const drafts = readQueue(path);
227
+ const draft = { id: draftId(args.type, title), ts: new Date().toISOString(), type: args.type, title, details, area, evidence, client };
228
+ const idx = drafts.findIndex((d) => d.id === draft.id);
229
+ if (idx >= 0) {
230
+ drafts[idx] = draft;
231
+ } else {
232
+ if (drafts.length >= QUEUE_CAP) {
233
+ 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);
234
+ }
235
+ drafts.push(draft);
236
+ }
237
+ writeQueue(path, drafts);
238
+ return text(
239
+ `Queued locally as draft ${draft.id}${idx >= 0 ? " (replaced an earlier draft with the same title)" : ""}. ${drafts.length} draft(s) pending in ${path}. ` +
240
+ `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", ` +
241
+ `then action "send" with only the ids the user names, or "discard".`,
242
+ );
243
+ }
244
+
245
+ const drafts = readQueue(path);
246
+
247
+ if (action === "list") {
248
+ if (drafts.length === 0) return text(`No feedback drafts pending (${path}).`);
249
+ return text(
250
+ `${drafts.length} feedback draft(s) pending in ${path}. Show these to the user and send only the ids they name:\n\n` +
251
+ drafts.map(summarize).join("\n"),
252
+ );
253
+ }
254
+
255
+ const ids = [...new Set((Array.isArray(args.ids) ? args.ids : []).map(String))];
256
+ if (ids.length === 0) return text(`action "${action}" needs ids: the draft ids the user named (from action "list").`, true);
257
+ const unknown = ids.filter((id) => !drafts.some((d) => d.id === id));
258
+ if (unknown.length) return text(`Unknown draft id(s): ${unknown.join(", ")}. Run action "list" to see the current ids.`, true);
259
+
260
+ if (action === "discard") {
261
+ const kept = drafts.filter((d) => !ids.includes(d.id));
262
+ writeQueue(path, kept);
263
+ return text(`Discarded ${ids.length} draft(s): ${ids.join(", ")}. ${kept.length} still pending.`);
264
+ }
265
+
266
+ return text(`action "${action}" is not handled here.`, true);
267
+ }
268
+ }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ 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 = (
@@ -81,6 +82,11 @@ if (!API_KEY) {
81
82
  // substitute into the URL template) and callEndpoint splices them into path
82
83
  // before building the query string or body, so a pathParams arg never leaks
83
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
+
84
90
  async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
85
91
  if (!API_KEY) {
86
92
  return {
@@ -168,11 +174,31 @@ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathPa
168
174
  : res.status >= 500
169
175
  ? " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)"
170
176
  : "";
171
- 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}` }] };
172
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;
173
198
  return { content: [{ type: "text", text: body }] };
174
199
  } catch (err) {
175
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() };
176
202
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
177
203
  } finally {
178
204
  clearTimeout(timer);
@@ -180,7 +206,27 @@ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathPa
180
206
  }
181
207
 
182
208
  // ── MCP server ───────────────────────────────────────────────────────────────
183
- 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
+ };
184
230
 
185
231
  for (const tool of TOOLS) {
186
232
  const method = tool.method || "GET";
@@ -192,10 +238,27 @@ for (const tool of TOOLS) {
192
238
  destructiveHint: Boolean(tool.destructive),
193
239
  openWorldHint: true,
194
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
+ }
195
258
  server.registerTool(
196
259
  tool.name,
197
260
  { description: tool.description, inputSchema: tool.shape, annotations },
198
- async (args) => callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []),
261
+ handler,
199
262
  );
200
263
  }
201
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: 96 tools (61 reads, 35 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 10
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.",