@twitterapis/mcp 0.9.6 → 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,6 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.9.6 (2026-09-04)
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.
4
8
 
5
9
  ### Added
6
10
 
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.6",
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",
package/src/feedback.js CHANGED
@@ -135,12 +135,69 @@ export function createFeedbackHandler({ callEndpoint, version, getClientInfo, ge
135
135
  if (!ACTIONS.includes(action)) return text(`action must be one of ${ACTIONS.join(", ")}.`, true);
136
136
  const path = queuePath(env);
137
137
  try {
138
+ if (action === "send") return await send(args, path);
138
139
  return await withLock(path, () => run(action, args, path));
139
140
  } catch (err) {
140
141
  return text(err?.message || String(err), true);
141
142
  }
142
143
  };
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
+
144
201
  async function run(action, args, path) {
145
202
  if (action === "draft") {
146
203
  if (!TYPES.includes(args.type)) return text(`type is required for a draft and must be one of ${TYPES.join(", ")}.`, true);
@@ -206,32 +263,6 @@ export function createFeedbackHandler({ callEndpoint, version, getClientInfo, ge
206
263
  return text(`Discarded ${ids.length} draft(s): ${ids.join(", ")}. ${kept.length} still pending.`);
207
264
  }
208
265
 
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);
266
+ return text(`action "${action}" is not handled here.`, true);
236
267
  }
237
268
  }