@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 +5 -1
- package/package.json +1 -1
- package/src/feedback.js +58 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.9.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|