@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 +11 -0
- package/README.md +12 -3
- package/package.json +2 -2
- package/src/feedback.js +268 -0
- package/src/index.js +66 -3
- package/src/tools.js +73 -23
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/feedback.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.",
|