@twitterapis/mcp 0.9.7 → 0.9.8

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.8 (2026-09-04)
4
+
5
+ ### Added
6
+
7
+ - **`twitter_feedback_list`, read the feedback reports this account has already sent.** The API shipped `GET /feedback` ("List Feedback") after 0.9.7 and no tool covered it, so `test/openapi-parity.mjs` was failing on `origin/main` against the live spec: 98 tools versus 99 endpoints. Because `prepublishOnly` runs `npm test`, that red suite also meant the package could not be published at all. The vendored `test/openapi.snapshot.json` was one route behind the live spec, which is why `scripts/gen-tools.mjs` could not see the endpoint either; refreshed, and exactly one route changed. Distinct from `twitter_feedback_send` action `"list"`, which shows LOCAL drafts that were never sent: this reads what the server holds. Free per call, `status`/`type` filters, cursor-paged. Catalog is now 99 tools, 63 reads and 36 writes, back to exact parity with the API's own endpoint count. **This tool lands BEFORE the route it calls is deployed, which is the deliberate order (surfaces first, route last), so it must not be PUBLISHED to npm until the backend deploy is live.** Probed 2026-09-05: `GET /feedback` returns a bare `404 Not Found`, byte-identical to a nonexistent path, while `GET /feedback/<uuid>` and `POST /feedback` both answer from the application, so the route is authored (twitterapis-backend `origin/feat/feedback-endpoint`, `07a83ad`) but not yet serving. Re-probe before any publish.
8
+
9
+ ### Fixed
10
+
11
+ - **`manifest.json` now declares its tool catalog statically, so the MCPB bundle carries capability metadata a registry can read without running the server.** Reproduced live: our Smithery listing scored 45/100 with "No capabilities found" even though `tools/list` already answers correctly with 98 tools and no API key (the 2026-08-18 lazy-validation fix). The MCPB manifest schema (v0.3) has an optional `tools: [{name, description}]` field plus a `tools_generated` flag for exactly this; it was never populated. A new `scripts/gen-manifest-tools.mjs` derives it from `src/tools.js` (the same generated catalog everything else in this repo is built from), wired into `npm run build`, `npm run bundle`, and `npm test` (`--check` mode) so it cannot go stale the way `src/tools.js` itself is guarded against.
12
+ - **Smithery's own listing for this server was a hard 404 ("Server Not Found or Removed"), not merely showing "No capabilities found" as reported.** Smithery was acquired by Arcade.dev (2026-08-05) and its publish model changed: local/stdio servers now require an explicit `.mcpb` bundle upload (`smithery mcp publish`) rather than an automatic scan of the GitHub repo. The Aug 18 listing did not survive that migration. Republished under `emma-fwab/twitterapis-mcp` (our Smithery org namespace) via the CLI; the listing is live again with correct description, repo, homepage and icon.
13
+ - **Known upstream limitation, not fixable from this repo: Smithery's publish backend rejects a `tools[]` entry that lacks `inputSchema`** ("expected object, received undefined" x98), but the official MCPB manifest schema's `tools` field forbids any key beyond `name`/`description` (`additionalProperties: false`), and `mcpb pack` itself refuses to build a bundle that adds one. Confirmed by testing both directions: a manifest with `inputSchema` fails `mcpb validate` and `mcpb pack` outright; a manifest without it publishes fine but Smithery shows "No capabilities found". Shipping the spec-compliant `name`/`description` list here is still correct (matches the documented format, harmless, and picks up automatically if Smithery relaxes their validator), but full capability display on Smithery is blocked on their side until that's resolved.
14
+
3
15
  ## 0.9.7 (2026-09-04)
4
16
 
5
17
  ### Fixed
package/README.md CHANGED
@@ -91,7 +91,7 @@ Restart Claude Desktop. The `twitter_*` tools appear in the tool picker.
91
91
 
92
92
  ## Tools
93
93
 
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).
94
+ 99 tools: 63 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. Four of the reads are free account lookups (`twitter_account_me`, `twitter_account_payments`, `twitter_feedback_get`, `twitter_feedback_list`); the 14 monitoring tools and `twitter_feedback_send` are also free (account administration, not metered reads).
95
95
 
96
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
 
@@ -109,7 +109,7 @@ Public reads (search, profiles, tweets, followers, likes) work with just your AP
109
109
  | `twitter_check_follow_relationship` | Follow relationship between two user ids (who follows whom) |
110
110
  | `twitter_user_tweets` | A user's recent original tweets (replies excluded) |
111
111
  | `twitter_user_tweets_and_replies` | A user's full timeline (tweets + replies) |
112
- | `twitter_user_tweets_complete` | A user's near-complete tweet history in one auto-paginated call |
112
+ | `twitter_user_tweets_complete` | A large batch of a user's tweet history per call (see [paging note](#paging-twitter_user_tweets_complete)) |
113
113
  | `twitter_user_media` | Images and videos a user has posted |
114
114
  | `twitter_user_mentions` | Recent public tweets mentioning a user |
115
115
  | `twitter_user_likes` | Tweets a user has liked (public Likes tab) |
@@ -214,6 +214,7 @@ Modelled on Claude Code's own feedback tool. When a call fails in a way that is
214
214
  |---|---|
215
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
216
  | `twitter_feedback_get` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response |
217
+ | `twitter_feedback_list` | List the reports this account has already sent, newest first, with `status`/`type` filters and a `cursor` to page |
217
218
 
218
219
  ### Session setup
219
220
 
@@ -265,6 +266,22 @@ url: "https://x.com/karpathy/status/1849....."
265
266
  First call, `twitter_user_followers`: `{ username: "openai", count: 100 }`
266
267
  Second call, pass back the `cursor` from the first response: `{ username: "openai", count: 100, cursor: "<cursor from response>" }`
267
268
 
269
+ ### Paging `twitter_user_tweets_complete`
270
+
271
+ > "Pull @elonmusk's whole tweet history"
272
+
273
+ `twitter_user_tweets_complete` auto-paginates server-side and returns a large batch per call, but it does **not** guarantee the full history in one call. Read the result like this:
274
+
275
+ - **`next_cursor` is the completion signal, not `count`.** Non-null means the history is truncated and more remains. Null means it is genuinely exhausted. The response also carries `has_more`, the same signal as a boolean.
276
+ - **`max` is a minimum target, not a cap.** Pages arrive in whole chunks, so a response may hold up to one page (<=100) more than requested. Live behaviour: `max=10` returned 20, `max=50` returned 60, `max=150` returned 161, and omitting `max` (server default **200**) returned 201. Never assume `count === max`.
277
+ - **Each call also has a server-side wall-clock budget**, so a response can be truncated even when it returned fewer tweets than requested. That is the second reason `count` cannot tell you whether you are done.
278
+ - **Billing is a flat $0.0024 per call**, regardless of how many tweets come back, so fewer large calls cost less than many small ones.
279
+
280
+ To resume, pass the `next_cursor` straight back in as `cursor` and repeat until it comes back null:
281
+
282
+ First call, `twitter_user_tweets_complete`: `{ user_id: "44196397", max: 800 }`
283
+ Then, while `next_cursor` is non-null: `{ user_id: "44196397", max: 800, cursor: "<next_cursor from previous response>" }`
284
+
268
285
  ### Monitor brand mentions
269
286
 
270
287
  > "Show me recent tweets mentioning @twitterapis"
@@ -306,7 +323,7 @@ Calls are billed to your twitterapis.com account. Almost every endpoint is $0.00
306
323
 
307
324
  **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.
308
325
 
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.
326
+ **Is it read-only?** No. 63 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.
310
327
 
311
328
  **Which clients are supported?** Claude Desktop, Cursor, Windsurf, and VS Code (Copilot agent mode), or any Model Context Protocol client.
312
329
 
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.7",
4
+ "version": "0.9.8",
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",
@@ -25,16 +25,18 @@
25
25
  "scripts": {
26
26
  "start": "node src/index.js",
27
27
  "check": "node --check src/index.js && node --check src/tools.js && node --check src/query.js",
28
- "build": "node scripts/gen-tools.mjs --write",
28
+ "build": "node scripts/gen-tools.mjs --write && node scripts/gen-manifest-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/feedback.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/body-mode-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs",
31
+ "test": "node scripts/gen-tools.mjs --check && node scripts/gen-manifest-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/hint-for.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 && node scripts/__tests__/reconcile-mcp-publish-chain.test.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",
35
35
  "check:firewall": "node test/firewall.mjs",
36
36
  "check:registry": "node test/registry-manifests.mjs",
37
- "bundle": "npx -y @anthropic-ai/mcpb@2.1.2 pack .",
37
+ "check:publish-chain": "node scripts/reconcile-mcp-publish-chain.mjs",
38
+ "check:manifest-tools": "node scripts/gen-manifest-tools.mjs --check",
39
+ "bundle": "node scripts/gen-manifest-tools.mjs --write && npx -y @anthropic-ai/mcpb@2.1.2 pack .",
38
40
  "check:mcpb": "npx -y @anthropic-ai/mcpb@2.1.2 validate manifest.json",
39
41
  "check:publish-provenance": "node test/publish-provenance.mjs"
40
42
  },
package/src/index.js CHANGED
@@ -87,6 +87,38 @@ if (!API_KEY) {
87
87
  // error branch; the tool name is added by the registration wrapper below.
88
88
  let lastError = null;
89
89
 
90
+ // The 404 hint used to be a flat status-only ternary telling EVERY caller that
91
+ // "the user, tweet, or list may have been deleted or the id is wrong". For a
92
+ // feedback id that sentence is simply wrong, and it sends a customer chasing a
93
+ // report id off to look at a tweet. The feedback routes are the first mounted
94
+ // outside the /twitter surface, so they are the first place the assumption is
95
+ // plainly visible; /account/* would have been next.
96
+ //
97
+ // ONLY THE 404 VARIES. Every other status is about the KEY, the CREDIT
98
+ // balance, the caller's SESSION, or OUR service, and each of those reads
99
+ // identically on every endpoint. Adding per-path branches for them would be
100
+ // surface area with no reader.
101
+ const NOT_FOUND_HINTS = [
102
+ [/^\/feedback/, " (not found. No feedback report with that id on this account, and an id from another account will not resolve here. Use the id returned by twitter_feedback_send action=send.)"],
103
+ [/^\/account/, " (not found. That account resource does not exist for this key.)"],
104
+ ];
105
+ const DEFAULT_NOT_FOUND_HINT =
106
+ " (not found. The user, tweet, or list may have been deleted or the id is wrong)";
107
+
108
+ export function hintFor(status, path) {
109
+ if (status === 401) return " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)";
110
+ if (status === 402) return " (insufficient credits, top up at https://www.twitterapis.com/dashboard)";
111
+ if (status === 403) return " (access forbidden. The resource may be private or your plan does not include this endpoint)";
112
+ if (status === 404) {
113
+ for (const [re, h] of NOT_FOUND_HINTS) if (re.test(path || "")) return h;
114
+ return DEFAULT_NOT_FOUND_HINT;
115
+ }
116
+ if (status === 409) return " (no authenticated X session for this key. Write actions and account-only reads (likes, bookmarks, DMs, home timeline, follow, post) require linking an X account/session to your key first; see https://www.twitterapis.com/dashboard)";
117
+ if (status === 429) return " (rate limited. Wait a few seconds and retry; reduce request frequency or increase TWITTERAPIS_TIMEOUT_MS if needed)";
118
+ if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)";
119
+ return "";
120
+ }
121
+
90
122
  async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
91
123
  if (!API_KEY) {
92
124
  return {
@@ -158,22 +190,7 @@ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathPa
158
190
  });
159
191
  const body = await res.text();
160
192
  if (!res.ok) {
161
- const hint =
162
- res.status === 401
163
- ? " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)"
164
- : res.status === 402
165
- ? " (insufficient credits, top up at https://www.twitterapis.com/dashboard)"
166
- : res.status === 403
167
- ? " (access forbidden. The resource may be private or your plan does not include this endpoint)"
168
- : res.status === 404
169
- ? " (not found. The user, tweet, or list may have been deleted or the id is wrong)"
170
- : res.status === 409
171
- ? " (no authenticated X session for this key. Write actions and account-only reads (likes, bookmarks, DMs, home timeline, follow, post) require linking an X account/session to your key first; see https://www.twitterapis.com/dashboard)"
172
- : res.status === 429
173
- ? " (rate limited. Wait a few seconds and retry; reduce request frequency or increase TWITTERAPIS_TIMEOUT_MS if needed)"
174
- : res.status >= 500
175
- ? " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)"
176
- : "";
193
+ const hint = hintFor(res.status, resolvedPath);
177
194
  lastError = {
178
195
  path: resolvedPath,
179
196
  method,
package/src/tools.js CHANGED
@@ -8,7 +8,7 @@
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: 98 tools (62 reads, 36 writes).
11
+ // Catalog: 99 tools (63 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
@@ -156,7 +156,7 @@ export const TOOLS = [
156
156
  name: "twitter_user_tweets",
157
157
  path: "/twitter/user/tweets",
158
158
  description:
159
- "Get a user's recent posting timeline. IMPORTANT: this endpoint does NOT filter server-side, so the response routinely includes retweets and replies alongside original posts. Every item carries is_retweet, is_reply and is_quote booleans, so filter client-side on those flags if you need originals only, and read author.username rather than assuming every item was written by the requested user (a retweet's retweeted_tweet holds the original author). Returns tweet text, id, timestamp, and engagement metrics. Paginate with cursor to go further back. For the full back-catalogue in one call, use twitter_user_tweets_complete.",
159
+ "Get a user's recent posting timeline. IMPORTANT: this endpoint does NOT filter server-side, so the response routinely includes retweets and replies alongside original posts. Every item carries is_retweet, is_reply and is_quote booleans, so filter client-side on those flags if you need originals only, and read author.username rather than assuming every item was written by the requested user (a retweet's retweeted_tweet holds the original author). Returns tweet text, id, timestamp, and engagement metrics. Paginate with cursor to go further back. To pull a back-catalogue in bulk with fewer round-trips, use twitter_user_tweets_complete (which is also cursor-paged, not one-shot).",
160
160
  shape: {
161
161
  username: z.string().optional().describe(
162
162
  "Twitter/X handle WITHOUT the leading @ (e.g. \"elonmusk\", \"openai\"). Provide exactly one of username or user_id.",
@@ -196,13 +196,16 @@ export const TOOLS = [
196
196
  name: "twitter_user_tweets_complete",
197
197
  path: "/twitter/user/tweets/complete",
198
198
  description:
199
- "Get a user's near-complete original-tweet history in a single call, auto-paginating server-side up to a cap (Twitter's ~3200-tweet per-user ceiling). Heavier than twitter_user_tweets; use when you want the whole back-catalogue at once rather than page-by-page. Returns a flat tweet array. Requires the numeric user_id (resolve a handle first with twitter_user_info).",
199
+ "Get a large batch of a user's tweet history in one call, auto-paginating server-side across upstream pages. Heavier than twitter_user_tweets; use it to pull a back-catalogue with fewer round-trips. Returns { count, next_cursor, has_more, tweets }. IMPORTANT, this does NOT guarantee the whole history in one call: next_cursor is the completion signal, NOT count. A non-null next_cursor means the history is TRUNCATED and more remains, so call this tool again with cursor set to that value, and repeat until next_cursor is null (has_more is the same signal as a boolean). Each call is bounded by BOTH max and a server-side wall-clock budget, so a response can be truncated even when it returned fewer tweets than you asked for, which is why count must never be used to decide whether you are done. Requires the numeric user_id (resolve a handle first with twitter_user_info). Billed a flat $0.0024 per call regardless of how many tweets come back, so fewer, larger calls are cheaper than many small ones.",
200
200
  shape: {
201
201
  user_id: z.string().describe(
202
202
  "Numeric Twitter/X user id. Required: this endpoint does not accept a username. Resolve a handle to a user_id first with twitter_user_info.",
203
203
  ),
204
204
  max: z.number().int().min(1).max(3200).optional().describe(
205
- "Maximum number of tweets to collect (default 800, hard ceiling 3200). Higher values take longer and cost more.",
205
+ "Target number of tweets to collect in this call. Defaults to 200 when omitted. This is a MINIMUM target, not a hard cap: pages arrive in whole chunks, so a response may contain up to one page (<=100) more than requested (measured live 2026-09-05: max=10 returned 20). Never assume count === max. Twitter's ~3200-per-user history ceiling still applies overall.",
206
+ ),
207
+ cursor: z.string().optional().describe(
208
+ "Resume point from a previous response's next_cursor. Omit on the first call. Pass it back to continue collecting where the last call stopped, and keep repeating while next_cursor is non-null.",
206
209
  ),
207
210
  },
208
211
  },
@@ -825,6 +828,26 @@ export const TOOLS = [
825
828
  ),
826
829
  },
827
830
  },
831
+ {
832
+ name: "twitter_feedback_list",
833
+ path: "/feedback",
834
+ description:
835
+ "List the feedback reports this account has already SENT to twitterapis.com, newest first. Use it when the user asks what they have reported, or to find the server id of an earlier report so twitter_feedback_get can read its full status. NOT the same as twitter_feedback_send action \"list\", which shows local drafts that have not been sent yet. Each item carries id, type, title, area, status (new, triaged, shipped or declined), the team's response if any, created_at and updated_at, and never details or evidence, so paging this can never bulk-export a report's body: read one by id with twitter_feedback_get for that. Page with cursor while next_cursor is non-null. Free per call, and shares a 10-per-minute limit with the other feedback tools.",
836
+ shape: {
837
+ limit: z.number().int().min(1).max(100).optional().describe(
838
+ "Max reports to return, 1 to 100. Defaults to 25. Anything outside that range is rejected with 400 naming limit.",
839
+ ),
840
+ cursor: z.string().optional().describe(
841
+ "Opaque continuation token from a previous response's next_cursor. Omit it to start from the newest report. A cursor that cannot be decoded is a 400 naming cursor, never a silently empty page.",
842
+ ),
843
+ status: z.enum(["new","triaged","shipped","declined"]).optional().describe(
844
+ "Optional. Return only reports in this state. Anything else is rejected with 400 naming status.",
845
+ ),
846
+ type: z.enum(["bug","idea","missing_capability"]).optional().describe(
847
+ "Optional. Return only reports of this kind. Anything else is rejected with 400 naming type.",
848
+ ),
849
+ },
850
+ },
828
851
  {
829
852
  name: "twitter_home_timeline",
830
853
  path: "/twitter/user/home_timeline",