@realaman90/x-mcp 1.0.0 → 2.0.0

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.
Files changed (4) hide show
  1. package/README.md +128 -37
  2. package/index.js +337 -17
  3. package/package.json +3 -3
  4. package/setup.js +171 -0
package/README.md CHANGED
@@ -1,38 +1,65 @@
1
1
  # x-mcp
2
2
 
3
- MCP server for X/Twitter API — give Claude (or any MCP client) the ability to search tweets, read profiles, get timelines, and read replies.
3
+ [![npm version](https://img.shields.io/npm/v/@realaman90/x-mcp)](https://www.npmjs.com/package/@realaman90/x-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- **Read-only. No posting, liking, or following. Safe to use with AI agents.**
6
+ MCP server for X/Twitter API — give Claude (or any MCP client) the ability to search, read, post, like, retweet, follow, bookmark, and more.
7
+
8
+ **27 tools total**: 8 read-only (Bearer token) + 19 write/advanced-read (OAuth 1.0a).
6
9
 
7
10
  ## Why
8
11
 
9
- - Find engagement opportunities on X without leaving Claude
10
- - Study reply patterns and conversations around any topic
11
- - Research accounts, their content, and audience reactions
12
- - All read-only no risk of accidental tweets or follows
12
+ - Search and analyze tweets without leaving your AI workflow
13
+ - Post, reply, quote-tweet, and run polls directly from Claude
14
+ - Like, retweet, follow, bookmark, block, mute — all from your terminal
15
+ - Works with Claude Code, Claude Desktop, Codex, or any MCP client
16
+ - OAuth credentials optional — runs read-only with just a Bearer token
13
17
 
14
18
  ## Tools
15
19
 
16
- | Tool | Description | X API Endpoint |
17
- |------|-------------|----------------|
18
- | `search_tweets` | Search recent tweets (last 7 days) with full query operators | `GET /2/tweets/search/recent` |
19
- | `get_user_profile` | Get user profile by username (bio, followers, etc.) | `GET /2/users/by/username/{username}` |
20
- | `get_user_tweets` | Get a user's recent tweets by user ID | `GET /2/users/{id}/tweets` |
21
- | `get_tweet_replies` | Get replies to a specific tweet | Search `conversation_id:{id}` |
22
- | `get_tweet` | Get a single tweet with full details and metrics | `GET /2/tweets/{id}` |
20
+ ### Always available (Bearer token only) 8 tools
21
+
22
+ | Tool | Description |
23
+ |------|-------------|
24
+ | `search_tweets` | Search recent tweets (last 7 days) with full query operators |
25
+ | `get_user_profile` | Get user profile by username (bio, followers, etc.) |
26
+ | `get_user_tweets` | Get a user's recent tweets by user ID |
27
+ | `get_tweet_replies` | Get replies to a specific tweet |
28
+ | `get_tweet` | Get a single tweet with full details and metrics |
29
+ | `get_user_followers` | Get a user's followers |
30
+ | `get_user_following` | Get who a user is following |
31
+ | `get_liking_users` | Get users who liked a tweet |
32
+
33
+ ### Requires OAuth 1.0a — 19 tools (auto-registered when credentials present)
34
+
35
+ | Tool | Description |
36
+ |------|-------------|
37
+ | `get_my_profile` | Get your own profile |
38
+ | `get_user_mentions` | Get tweets mentioning a user |
39
+ | `get_quote_tweets` | Get quote tweets of a tweet |
40
+ | `get_bookmarks` | Get your bookmarked tweets |
41
+ | `get_trending_topics` | Get trending topics by location |
42
+ | `create_post` | Post a tweet (text, reply, quote, poll) |
43
+ | `delete_post` | Delete your own tweet |
44
+ | `like_post` / `unlike_post` | Like or unlike a tweet |
45
+ | `repost` / `unrepost` | Retweet or undo retweet |
46
+ | `follow_user` / `unfollow_user` | Follow or unfollow a user |
47
+ | `bookmark_post` / `unbookmark_post` | Bookmark or remove bookmark |
48
+ | `block_user` / `unblock_user` | Block or unblock a user |
49
+ | `mute_user` / `unmute_user` | Mute or unmute a user |
23
50
 
24
51
  ## Quick Start
25
52
 
26
- ### 1. Get an X API Bearer Token
53
+ ### 1. Get X API credentials
27
54
 
28
- 1. Go to [developer.x.com](https://developer.x.com) and sign in
29
- 2. Click **Developer Portal** **Projects & Apps**
30
- 3. Create a new **Project** (any name)
31
- 4. Create an **App** inside the project
32
- 5. Go to **Keys and Tokens** generate a **Bearer Token**
33
- 6. Copy the token you'll need it in step 2
55
+ 1. Go to [developer.x.com](https://developer.x.com) **Developer Portal** → **Projects & Apps**
56
+ 2. Create a **Project** and an **App** inside it
57
+ 3. Go to **Keys and Tokens**:
58
+ - Copy the **Bearer Token** (required)
59
+ - Copy the **API Key** and **API Secret** (for write access)
60
+ - Generate and copy the **Access Token** and **Access Token Secret** (for write access)
34
61
 
35
- > The **Basic** tier ($200/mo pay-per-usage) is sufficient. Free tier works too but has lower rate limits.
62
+ > **Read-only mode**: Only the Bearer Token is required. The 8 read tools work without OAuth.
36
63
 
37
64
  ### 2. Add to Claude Code
38
65
 
@@ -44,18 +71,22 @@ Add to `~/.claude/settings.json`:
44
71
  "x": {
45
72
  "type": "stdio",
46
73
  "command": "npx",
47
- "args": ["-y", "x-mcp"],
74
+ "args": ["-y", "@realaman90/x-mcp"],
48
75
  "env": {
49
- "X_BEARER_TOKEN": "your-bearer-token"
76
+ "X_BEARER_TOKEN": "your-bearer-token",
77
+ "X_API_KEY": "your-api-key",
78
+ "X_API_SECRET": "your-api-secret",
79
+ "X_ACCESS_TOKEN": "your-access-token",
80
+ "X_ACCESS_TOKEN_SECRET": "your-access-token-secret"
50
81
  }
51
82
  }
52
83
  }
53
84
  }
54
85
  ```
55
86
 
56
- ### Or add to Claude Desktop
87
+ Or add to **Claude Desktop**: **Settings** → **Developer** → **Edit Config** → same block.
57
88
 
58
- Open **Settings** **Developer** **Edit Config** and add the same block above.
89
+ > Omit the `X_API_KEY`/`X_API_SECRET`/`X_ACCESS_TOKEN`/`X_ACCESS_TOKEN_SECRET` lines for read-only mode.
59
90
 
60
91
  ### 3. Restart Claude and test
61
92
 
@@ -63,41 +94,101 @@ Open **Settings** → **Developer** → **Edit Config** and add the same block a
63
94
  Search X for "AI agents" in English, no retweets
64
95
  ```
65
96
 
66
- Claude will use the `search_tweets` tool automatically.
97
+ ```
98
+ Post a tweet: "Hello from Claude!"
99
+ ```
100
+
101
+ ## Team Setup (sharing your app with others)
102
+
103
+ If you want a colleague to use your X app but post from **their own account**:
104
+
105
+ ### App owner (one-time)
106
+
107
+ 1. In [X Developer Portal](https://developer.x.com) → your app → **Authentication Settings**:
108
+ - App permissions: **Read and write**
109
+ - Type of App: **Web App, Automated App or Bot**
110
+ - Callback URL: `http://localhost:3456/callback`
111
+ 2. Share your **API Key**, **API Secret**, and **Bearer Token** with your colleague
112
+
113
+ ### Colleague (one-time)
114
+
115
+ ```bash
116
+ npx @realaman90/x-mcp --setup
117
+ ```
118
+
119
+ Or if running from source:
120
+
121
+ ```bash
122
+ X_API_KEY=<app_api_key> X_API_SECRET=<app_api_secret> node setup.js
123
+ ```
124
+
125
+ This will:
126
+ 1. Open your browser to X's authorization page
127
+ 2. You log in with **your** X account and click "Authorize"
128
+ 3. Print your personal **Access Token** and **Access Token Secret**
129
+ 4. Add those to your Claude config — done. Tokens never expire.
67
130
 
68
131
  ## Usage Examples
69
132
 
70
133
  ### Search tweets
71
134
  ```
72
- "AI video" lang:en -is:retweet # English tweets about AI video, no RTs
135
+ "AI video" lang:en -is:retweet # English tweets about AI video
73
136
  from:elonmusk has:media # Elon's tweets with media
74
137
  #buildinpublic -is:reply # Hashtag, original tweets only
75
- "machine learning" has:links min_faves:10 # ML tweets with links, 10+ likes
76
138
  ```
77
139
 
78
- ### Chain tools together
140
+ ### Post and engage
141
+ ```
142
+ Post a tweet: "Shipping a new feature today 🚀"
143
+ Reply to tweet 1234567890 saying "Great thread!"
144
+ Like tweet 1234567890
145
+ Retweet the latest tweet from @username
146
+ ```
147
+
148
+ ### Chain tools
79
149
  1. `get_user_profile` → get user ID from username
80
150
  2. `get_user_tweets` → get their recent tweets
81
- 3. `get_tweet_replies` → see replies on a specific tweet
151
+ 3. `like_post` → like a specific tweet
152
+ 4. `create_post` → reply to it
82
153
 
83
- ### Run directly (without Claude)
84
- ```bash
85
- X_BEARER_TOKEN="your-token" npx x-mcp
86
- ```
154
+ ## Environment Variables
155
+
156
+ | Variable | Required | Description |
157
+ |----------|----------|-------------|
158
+ | `X_BEARER_TOKEN` | Yes | Bearer token for read-only API access |
159
+ | `X_API_KEY` | For write | OAuth 1.0a consumer key (API Key) |
160
+ | `X_API_SECRET` | For write | OAuth 1.0a consumer secret (API Secret) |
161
+ | `X_ACCESS_TOKEN` | For write | OAuth 1.0a access token (per-user) |
162
+ | `X_ACCESS_TOKEN_SECRET` | For write | OAuth 1.0a access token secret (per-user) |
87
163
 
88
164
  ## Requirements
89
165
 
90
166
  - **Node.js 18+** (for native `fetch`)
91
- - **X API Bearer Token** — [Get one here](https://developer.x.com)
167
+ - **X API access** — [Get it here](https://developer.x.com)
92
168
 
93
169
  ## How It Works
94
170
 
95
- Single-file MCP server (~120 lines). No build step, no config files. Reads `X_BEARER_TOKEN` from environment, connects via stdio.
171
+ Single-file MCP server (~430 lines). No build step, no config files, zero extra dependencies. Uses Node.js built-in `crypto` for OAuth signing.
96
172
 
97
173
  ```
98
- Claude ↔ stdio ↔ x-mcp ↔ X API v2
174
+ Claude ↔ stdio ↔ x-mcp ↔ X API v1.1/v2
175
+
176
+ Bearer (read) + OAuth 1.0a (write)
99
177
  ```
100
178
 
179
+ ## Contributing
180
+
181
+ Contributions welcome! Feel free to open issues or submit PRs.
182
+
183
+ 1. Fork the repo
184
+ 2. Create your branch (`git checkout -b feature/my-feature`)
185
+ 3. Commit your changes
186
+ 4. Push and open a Pull Request
187
+
188
+ ## Author
189
+
190
+ Built by [@amanrawatamg](https://x.com/amanrawatamg)
191
+
101
192
  ## License
102
193
 
103
194
  MIT
package/index.js CHANGED
@@ -1,18 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // ─── Setup mode (npx @realaman90/x-mcp --setup) ───────────────────────────────
4
+
5
+ if (process.argv.includes("--setup")) {
6
+ const { fileURLToPath } = await import("url");
7
+ const { dirname, join } = await import("path");
8
+ const dir = dirname(fileURLToPath(import.meta.url));
9
+ await import(join(dir, "setup.js"));
10
+ process.exit(0);
11
+ }
12
+
13
+ import { createHmac, randomBytes } from "crypto";
3
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
16
  import { z } from "zod";
6
17
 
18
+ // ─── Auth config ───────────────────────────────────────────────────────────────
19
+
7
20
  const BEARER_TOKEN = decodeURIComponent(process.env.X_BEARER_TOKEN || "");
8
21
  if (!BEARER_TOKEN) {
9
22
  console.error("X_BEARER_TOKEN environment variable is required");
10
23
  process.exit(1);
11
24
  }
12
25
 
26
+ const OAUTH = {
27
+ consumerKey: process.env.X_API_KEY || "",
28
+ consumerSecret: process.env.X_API_SECRET || "",
29
+ token: process.env.X_ACCESS_TOKEN || "",
30
+ tokenSecret: process.env.X_ACCESS_TOKEN_SECRET || "",
31
+ };
32
+ const HAS_OAUTH = !!(OAUTH.consumerKey && OAUTH.consumerSecret && OAUTH.token && OAUTH.tokenSecret);
33
+
34
+ // ─── Shared constants ──────────────────────────────────────────────────────────
35
+
13
36
  const TWEET_FIELDS = "created_at,public_metrics,author_id,conversation_id,in_reply_to_user_id,lang";
14
37
  const USER_FIELDS = "created_at,description,public_metrics,profile_image_url,url,verified";
15
38
 
39
+ // ─── Bearer fetch (read-only, no user context) ────────────────────────────────
40
+
16
41
  async function xapi(endpoint, params = {}) {
17
42
  const url = new URL(`https://api.x.com/2${endpoint}`);
18
43
  for (const [k, v] of Object.entries(params)) {
@@ -28,11 +53,99 @@ async function xapi(endpoint, params = {}) {
28
53
  return res.json();
29
54
  }
30
55
 
56
+ // ─── OAuth 1.0a signing (HMAC-SHA1) ───────────────────────────────────────────
57
+
58
+ function enc(str) {
59
+ return encodeURIComponent(str).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());
60
+ }
61
+
62
+ function oauthSign(method, url, queryParams = {}) {
63
+ const ts = Math.floor(Date.now() / 1000).toString();
64
+ const nonce = randomBytes(16).toString("hex");
65
+
66
+ const oauthParams = {
67
+ oauth_consumer_key: OAUTH.consumerKey,
68
+ oauth_nonce: nonce,
69
+ oauth_signature_method: "HMAC-SHA1",
70
+ oauth_timestamp: ts,
71
+ oauth_token: OAUTH.token,
72
+ oauth_version: "1.0",
73
+ };
74
+
75
+ // Combine oauth params + query params, sort, encode
76
+ const all = { ...oauthParams, ...queryParams };
77
+ const paramStr = Object.keys(all)
78
+ .sort()
79
+ .map(k => `${enc(k)}=${enc(all[k])}`)
80
+ .join("&");
81
+
82
+ const baseStr = `${method.toUpperCase()}&${enc(url)}&${enc(paramStr)}`;
83
+ const signingKey = `${enc(OAUTH.consumerSecret)}&${enc(OAUTH.tokenSecret)}`;
84
+ const sig = createHmac("sha1", signingKey).update(baseStr).digest("base64");
85
+
86
+ oauthParams.oauth_signature = sig;
87
+ const header = "OAuth " + Object.keys(oauthParams)
88
+ .sort()
89
+ .map(k => `${enc(k)}="${enc(oauthParams[k])}"`)
90
+ .join(", ");
91
+
92
+ return header;
93
+ }
94
+
95
+ async function xapiAuth(method, endpoint, body) {
96
+ const isV1 = endpoint.startsWith("/1.1/");
97
+ const base = isV1 ? "https://api.x.com" : "https://api.x.com/2";
98
+ const fullUrl = `${base}${isV1 ? endpoint : endpoint}`;
99
+
100
+ // Parse any query params from endpoint for signing
101
+ const urlObj = new URL(fullUrl);
102
+ const queryParams = {};
103
+ for (const [k, v] of urlObj.searchParams) queryParams[k] = v;
104
+
105
+ const authHeader = oauthSign(method, urlObj.origin + urlObj.pathname, queryParams);
106
+
107
+ const opts = {
108
+ method,
109
+ headers: { Authorization: authHeader },
110
+ };
111
+ if (body && (method === "POST" || method === "PUT")) {
112
+ opts.headers["Content-Type"] = "application/json";
113
+ opts.body = JSON.stringify(body);
114
+ }
115
+
116
+ const res = await fetch(fullUrl, opts);
117
+ // DELETE endpoints return 204 with empty body
118
+ if (res.status === 204) return { success: true };
119
+ if (!res.ok) {
120
+ const text = await res.text();
121
+ throw new Error(`X API ${res.status}: ${text}`);
122
+ }
123
+ return res.json();
124
+ }
125
+
126
+ // ─── Cached authenticated user ID ──────────────────────────────────────────────
127
+
128
+ let _myUserId = null;
129
+ async function getMyUserId() {
130
+ if (_myUserId) return _myUserId;
131
+ const data = await xapiAuth("GET", "/users/me", null);
132
+ _myUserId = data.data.id;
133
+ return _myUserId;
134
+ }
135
+
136
+ // ─── MCP Server ────────────────────────────────────────────────────────────────
137
+
31
138
  const server = new McpServer({
32
139
  name: "x-mcp",
33
- version: "1.0.0",
140
+ version: "2.0.0",
34
141
  });
35
142
 
143
+ const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
144
+
145
+ // ═══════════════════════════════════════════════════════════════════════════════
146
+ // BEARER-ONLY TOOLS (always registered)
147
+ // ═══════════════════════════════════════════════════════════════════════════════
148
+
36
149
  // 1. Search tweets
37
150
  server.tool(
38
151
  "search_tweets",
@@ -43,13 +156,12 @@ server.tool(
43
156
  },
44
157
  async ({ query, max_results }) => {
45
158
  const data = await xapi("/tweets/search/recent", {
46
- query,
47
- max_results,
159
+ query, max_results,
48
160
  "tweet.fields": TWEET_FIELDS,
49
161
  expansions: "author_id",
50
162
  "user.fields": "username,name",
51
163
  });
52
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
164
+ return ok(data);
53
165
  }
54
166
  );
55
167
 
@@ -57,14 +169,10 @@ server.tool(
57
169
  server.tool(
58
170
  "get_user_profile",
59
171
  "Get an X/Twitter user's profile by username. Returns bio, follower counts, and account details.",
60
- {
61
- username: z.string().describe("X username (without @)"),
62
- },
172
+ { username: z.string().describe("X username (without @)") },
63
173
  async ({ username }) => {
64
- const data = await xapi(`/users/by/username/${username}`, {
65
- "user.fields": USER_FIELDS,
66
- });
67
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
174
+ const data = await xapi(`/users/by/username/${username}`, { "user.fields": USER_FIELDS });
175
+ return ok(data);
68
176
  }
69
177
  );
70
178
 
@@ -81,7 +189,7 @@ server.tool(
81
189
  max_results,
82
190
  "tweet.fields": TWEET_FIELDS,
83
191
  });
84
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
192
+ return ok(data);
85
193
  }
86
194
  );
87
195
 
@@ -101,7 +209,7 @@ server.tool(
101
209
  expansions: "author_id",
102
210
  "user.fields": "username,name",
103
211
  });
104
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
212
+ return ok(data);
105
213
  }
106
214
  );
107
215
 
@@ -109,18 +217,230 @@ server.tool(
109
217
  server.tool(
110
218
  "get_tweet",
111
219
  "Get a single tweet by ID with full details including metrics, author info, and conversation context.",
112
- {
113
- tweet_id: z.string().describe("Tweet ID"),
114
- },
220
+ { tweet_id: z.string().describe("Tweet ID") },
115
221
  async ({ tweet_id }) => {
116
222
  const data = await xapi(`/tweets/${tweet_id}`, {
117
223
  "tweet.fields": TWEET_FIELDS,
118
224
  expansions: "author_id",
119
225
  "user.fields": "username,name",
120
226
  });
121
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
227
+ return ok(data);
228
+ }
229
+ );
230
+
231
+ // 6. Get user's followers
232
+ server.tool(
233
+ "get_user_followers",
234
+ "Get a list of users who follow the specified user. Use get_user_profile first to get the user ID.",
235
+ {
236
+ user_id: z.string().describe("X user ID (numeric string)"),
237
+ max_results: z.number().min(1).max(1000).default(100).describe("Number of results (1-1000)"),
238
+ },
239
+ async ({ user_id, max_results }) => {
240
+ const data = await xapi(`/users/${user_id}/followers`, {
241
+ max_results,
242
+ "user.fields": USER_FIELDS,
243
+ });
244
+ return ok(data);
122
245
  }
123
246
  );
124
247
 
248
+ // 7. Get user's following
249
+ server.tool(
250
+ "get_user_following",
251
+ "Get a list of users the specified user is following. Use get_user_profile first to get the user ID.",
252
+ {
253
+ user_id: z.string().describe("X user ID (numeric string)"),
254
+ max_results: z.number().min(1).max(1000).default(100).describe("Number of results (1-1000)"),
255
+ },
256
+ async ({ user_id, max_results }) => {
257
+ const data = await xapi(`/users/${user_id}/following`, {
258
+ max_results,
259
+ "user.fields": USER_FIELDS,
260
+ });
261
+ return ok(data);
262
+ }
263
+ );
264
+
265
+ // 8. Get users who liked a tweet
266
+ server.tool(
267
+ "get_liking_users",
268
+ "Get a list of users who liked a specific tweet.",
269
+ {
270
+ tweet_id: z.string().describe("Tweet ID"),
271
+ max_results: z.number().min(1).max(100).default(100).describe("Number of results (1-100)"),
272
+ },
273
+ async ({ tweet_id, max_results }) => {
274
+ const data = await xapi(`/tweets/${tweet_id}/liking_users`, {
275
+ max_results,
276
+ "user.fields": USER_FIELDS,
277
+ });
278
+ return ok(data);
279
+ }
280
+ );
281
+
282
+ // ═══════════════════════════════════════════════════════════════════════════════
283
+ // OAUTH TOOLS (only registered when OAuth credentials are present)
284
+ // ═══════════════════════════════════════════════════════════════════════════════
285
+
286
+ if (HAS_OAUTH) {
287
+
288
+ // ─── OAuth Read Tools ──────────────────────────────────────────────────────
289
+
290
+ // 9. Get my profile
291
+ server.tool(
292
+ "get_my_profile",
293
+ "Get the authenticated user's own profile. Requires OAuth — returns your user ID, bio, metrics, etc.",
294
+ {},
295
+ async () => {
296
+ const data = await xapiAuth("GET", "/users/me?user.fields=" + encodeURIComponent(USER_FIELDS), null);
297
+ return ok(data);
298
+ }
299
+ );
300
+
301
+ // 10. Get user mentions
302
+ server.tool(
303
+ "get_user_mentions",
304
+ "Get recent tweets mentioning a specific user. Requires OAuth for user-context access.",
305
+ {
306
+ user_id: z.string().describe("X user ID (numeric string)"),
307
+ max_results: z.number().min(5).max(100).default(10).describe("Number of results (5-100)"),
308
+ },
309
+ async ({ user_id, max_results }) => {
310
+ const data = await xapiAuth("GET",
311
+ `/users/${user_id}/mentions?max_results=${max_results}&tweet.fields=${encodeURIComponent(TWEET_FIELDS)}&expansions=author_id&user.fields=${encodeURIComponent("username,name")}`,
312
+ null
313
+ );
314
+ return ok(data);
315
+ }
316
+ );
317
+
318
+ // 11. Get quote tweets
319
+ server.tool(
320
+ "get_quote_tweets",
321
+ "Get tweets that quote a specific tweet. Requires OAuth for user-context access.",
322
+ {
323
+ tweet_id: z.string().describe("Tweet ID"),
324
+ max_results: z.number().min(10).max(100).default(10).describe("Number of results (10-100)"),
325
+ },
326
+ async ({ tweet_id, max_results }) => {
327
+ const data = await xapiAuth("GET",
328
+ `/tweets/${tweet_id}/quote_tweets?max_results=${max_results}&tweet.fields=${encodeURIComponent(TWEET_FIELDS)}&expansions=author_id&user.fields=${encodeURIComponent("username,name")}`,
329
+ null
330
+ );
331
+ return ok(data);
332
+ }
333
+ );
334
+
335
+ // 12. Get bookmarks
336
+ server.tool(
337
+ "get_bookmarks",
338
+ "Get the authenticated user's bookmarked tweets. Bearer token is explicitly forbidden for this endpoint.",
339
+ {
340
+ max_results: z.number().min(1).max(100).default(20).describe("Number of results (1-100)"),
341
+ },
342
+ async ({ max_results }) => {
343
+ const myId = await getMyUserId();
344
+ const data = await xapiAuth("GET",
345
+ `/users/${myId}/bookmarks?max_results=${max_results}&tweet.fields=${encodeURIComponent(TWEET_FIELDS)}&expansions=author_id&user.fields=${encodeURIComponent("username,name")}`,
346
+ null
347
+ );
348
+ return ok(data);
349
+ }
350
+ );
351
+
352
+ // 13. Get trending topics
353
+ server.tool(
354
+ "get_trending_topics",
355
+ "Get current trending topics for a location. Uses v1.1 API. Default WOEID 1 = worldwide, 23424977 = US.",
356
+ {
357
+ woeid: z.number().default(1).describe("Where On Earth ID (1=worldwide, 23424977=US, 23424975=UK)"),
358
+ },
359
+ async ({ woeid }) => {
360
+ const data = await xapiAuth("GET", `/1.1/trends/place.json?id=${woeid}`, null);
361
+ return ok(data);
362
+ }
363
+ );
364
+
365
+ // ─── OAuth Write Tools ─────────────────────────────────────────────────────
366
+
367
+ // 14. Create post (most complex write tool)
368
+ server.tool(
369
+ "create_post",
370
+ "Create a new tweet/post on X. Supports text, replies, quote tweets, and polls. Max 280 characters for text.",
371
+ {
372
+ text: z.string().max(280).describe("Tweet text (max 280 characters)"),
373
+ reply_to: z.string().optional().describe("Tweet ID to reply to (makes this a reply)"),
374
+ quote_tweet_id: z.string().optional().describe("Tweet ID to quote (makes this a quote tweet)"),
375
+ poll_options: z.array(z.string()).min(2).max(4).optional().describe("Poll options (2-4 choices). Creates a poll attached to the tweet."),
376
+ poll_duration_minutes: z.number().min(5).max(10080).optional().describe("Poll duration in minutes (5-10080, default 1440 = 24h). Only used with poll_options."),
377
+ },
378
+ async ({ text, reply_to, quote_tweet_id, poll_options, poll_duration_minutes }) => {
379
+ const body = { text };
380
+ if (reply_to) body.reply = { in_reply_to_tweet_id: reply_to };
381
+ if (quote_tweet_id) body.quote_tweet_id = quote_tweet_id;
382
+ if (poll_options) {
383
+ body.poll = {
384
+ options: poll_options,
385
+ duration_minutes: poll_duration_minutes || 1440,
386
+ };
387
+ }
388
+ const data = await xapiAuth("POST", "/tweets", body);
389
+ return ok(data);
390
+ }
391
+ );
392
+
393
+ // 15. Delete post
394
+ server.tool(
395
+ "delete_post",
396
+ "Delete one of your own tweets by ID. This action is irreversible.",
397
+ { tweet_id: z.string().describe("Tweet ID to delete (must be your own tweet)") },
398
+ async ({ tweet_id }) => {
399
+ const data = await xapiAuth("DELETE", `/tweets/${tweet_id}`, null);
400
+ return ok(data);
401
+ }
402
+ );
403
+
404
+ // 16-27. Toggle tools (like/unlike, repost/unrepost, etc.)
405
+ const toggleTools = [
406
+ ["like_post", "Like a tweet", "POST", (me) => `/users/${me}/likes`, "tweet_id", (id) => ({ tweet_id: id })],
407
+ ["unlike_post", "Unlike a previously liked tweet", "DELETE", (me) => `/users/${me}/likes/${"{id}"}`, "tweet_id", null],
408
+ ["repost", "Repost (retweet) a tweet", "POST", (me) => `/users/${me}/retweets`, "tweet_id", (id) => ({ tweet_id: id })],
409
+ ["unrepost", "Undo a repost (unretweet)", "DELETE", (me) => `/users/${me}/retweets/${"{id}"}`, "tweet_id", null],
410
+ ["follow_user", "Follow a user", "POST", (me) => `/users/${me}/following`, "target_user_id", (id) => ({ target_user_id: id })],
411
+ ["unfollow_user", "Unfollow a user", "DELETE", (me) => `/users/${me}/following/${"{id}"}`, "target_user_id", null],
412
+ ["bookmark_post", "Bookmark a tweet", "POST", (me) => `/users/${me}/bookmarks`, "tweet_id", (id) => ({ tweet_id: id })],
413
+ ["unbookmark_post", "Remove a tweet from bookmarks", "DELETE", (me) => `/users/${me}/bookmarks/${"{id}"}`, "tweet_id", null],
414
+ ["block_user", "Block a user", "POST", (me) => `/users/${me}/blocking`, "target_user_id", (id) => ({ target_user_id: id })],
415
+ ["unblock_user", "Unblock a user", "DELETE", (me) => `/users/${me}/blocking/${"{id}"}`, "target_user_id", null],
416
+ ["mute_user", "Mute a user", "POST", (me) => `/users/${me}/muting`, "target_user_id", (id) => ({ target_user_id: id })],
417
+ ["unmute_user", "Unmute a user", "DELETE", (me) => `/users/${me}/muting/${"{id}"}`, "target_user_id", null],
418
+ ];
419
+
420
+ for (const [name, desc, method, pathFn, paramName, bodyFn] of toggleTools) {
421
+ const paramDesc = paramName === "tweet_id" ? "Tweet ID" : "Target user ID (numeric string)";
422
+ server.tool(
423
+ name,
424
+ desc,
425
+ { [paramName]: z.string().describe(paramDesc) },
426
+ async (input) => {
427
+ const myId = await getMyUserId();
428
+ const id = input[paramName];
429
+ const path = pathFn(myId).replace("{id}", id);
430
+ const body = bodyFn ? bodyFn(id) : null;
431
+ const data = await xapiAuth(method, path, body);
432
+ return ok(data);
433
+ }
434
+ );
435
+ }
436
+ }
437
+
438
+ // ─── Start server ──────────────────────────────────────────────────────────────
439
+
440
+ if (!HAS_OAUTH) {
441
+ console.error("OAuth credentials not found — running with 8 read-only tools (Bearer token only)");
442
+ console.error("Set X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET for full 27-tool access");
443
+ }
444
+
125
445
  const transport = new StdioServerTransport();
126
446
  await server.connect(transport);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@realaman90/x-mcp",
3
- "version": "1.0.0",
4
- "description": "MCP server for X/Twitter API — search tweets, read profiles, get timelines and replies",
3
+ "version": "2.0.0",
4
+ "description": "MCP server for X/Twitter API — read tweets, profiles, timelines + write posts, likes, retweets, follows, bookmarks, and more",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
8
  "x-mcp": "index.js"
9
9
  },
10
- "keywords": ["mcp", "twitter", "x", "claude", "ai"],
10
+ "keywords": ["mcp", "twitter", "x", "claude", "ai", "oauth", "post", "tweet"],
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.12.1",
package/setup.js ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * One-time OAuth 1.0a setup for x-mcp.
5
+ *
6
+ * Run this when a new user wants to connect their X account to your app.
7
+ * It opens a browser, user clicks "Authorize", and you get their Access Token/Secret.
8
+ *
9
+ * Prerequisites:
10
+ * - X_API_KEY and X_API_SECRET env vars set (app's Consumer Key/Secret)
11
+ * - App callback URL set to http://localhost:3456/callback in X Developer Portal
12
+ *
13
+ * Usage:
14
+ * X_API_KEY=... X_API_SECRET=... node setup.js
15
+ */
16
+
17
+ import { createHmac, randomBytes } from "crypto";
18
+ import { createServer } from "http";
19
+
20
+ const API_KEY = process.env.X_API_KEY;
21
+ const API_SECRET = process.env.X_API_SECRET;
22
+ const BEARER_TOKEN = process.env.X_BEARER_TOKEN;
23
+
24
+ if (!API_KEY || !API_SECRET) {
25
+ console.error("\n Missing env vars. Run with:\n");
26
+ console.error(" X_BEARER_TOKEN=... X_API_KEY=... X_API_SECRET=... node setup.js\n");
27
+ console.error(" (X_BEARER_TOKEN is optional here but needed for the MCP server)\n");
28
+ process.exit(1);
29
+ }
30
+
31
+ const CALLBACK_URL = "http://localhost:3456/callback";
32
+
33
+ // ─── OAuth 1.0a helpers ────────────────────────────────────────────────────────
34
+
35
+ function enc(str) {
36
+ return encodeURIComponent(str).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());
37
+ }
38
+
39
+ function sign(method, url, params, tokenSecret = "") {
40
+ const ts = Math.floor(Date.now() / 1000).toString();
41
+ const nonce = randomBytes(16).toString("hex");
42
+
43
+ const oauthParams = {
44
+ oauth_consumer_key: API_KEY,
45
+ oauth_nonce: nonce,
46
+ oauth_signature_method: "HMAC-SHA1",
47
+ oauth_timestamp: ts,
48
+ oauth_version: "1.0",
49
+ ...params,
50
+ };
51
+
52
+ const all = { ...oauthParams };
53
+ const paramStr = Object.keys(all).sort().map(k => `${enc(k)}=${enc(all[k])}`).join("&");
54
+ const baseStr = `${method}&${enc(url)}&${enc(paramStr)}`;
55
+ const signingKey = `${enc(API_SECRET)}&${enc(tokenSecret)}`;
56
+ const sig = createHmac("sha1", signingKey).update(baseStr).digest("base64");
57
+
58
+ oauthParams.oauth_signature = sig;
59
+ return "OAuth " + Object.keys(oauthParams).sort().map(k => `${enc(k)}="${enc(oauthParams[k])}"`).join(", ");
60
+ }
61
+
62
+ // ─── Step 1: Get request token ─────────────────────────────────────────────────
63
+
64
+ console.log("\n 🔑 x-mcp OAuth Setup\n");
65
+ console.log(" Step 1/3: Requesting temporary token...");
66
+
67
+ const reqTokenUrl = "https://api.x.com/oauth/request_token";
68
+ const reqTokenAuth = sign("POST", reqTokenUrl, { oauth_callback: CALLBACK_URL });
69
+
70
+ const reqTokenRes = await fetch(reqTokenUrl, {
71
+ method: "POST",
72
+ headers: { Authorization: reqTokenAuth },
73
+ });
74
+
75
+ if (!reqTokenRes.ok) {
76
+ const body = await reqTokenRes.text();
77
+ console.error(`\n ❌ Failed to get request token: ${reqTokenRes.status}\n ${body}`);
78
+ console.error("\n Make sure your callback URL is set to http://localhost:3456/callback in X Developer Portal");
79
+ process.exit(1);
80
+ }
81
+
82
+ const reqTokenBody = await reqTokenRes.text();
83
+ const reqTokenParams = new URLSearchParams(reqTokenBody);
84
+ const oauthToken = reqTokenParams.get("oauth_token");
85
+ const oauthTokenSecret = reqTokenParams.get("oauth_token_secret");
86
+
87
+ // ─── Step 2: User authorizes in browser ────────────────────────────────────────
88
+
89
+ const authorizeUrl = `https://api.x.com/oauth/authorize?oauth_token=${oauthToken}`;
90
+
91
+ console.log(" Step 2/3: Opening browser for authorization...\n");
92
+ console.log(` If browser doesn't open, visit:\n ${authorizeUrl}\n`);
93
+
94
+ // Open browser cross-platform
95
+ const { exec } = await import("child_process");
96
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
97
+ exec(`${openCmd} "${authorizeUrl}"`);
98
+
99
+ // ─── Step 3: Wait for callback, exchange for access token ──────────────────────
100
+
101
+ const verifier = await new Promise((resolve, reject) => {
102
+ const srv = createServer((req, res) => {
103
+ const url = new URL(req.url, "http://localhost:3456");
104
+ if (url.pathname === "/callback") {
105
+ const v = url.searchParams.get("oauth_verifier");
106
+ res.writeHead(200, { "Content-Type": "text/html" });
107
+ res.end(`
108
+ <html><body style="background:#000;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
109
+ <div style="text-align:center">
110
+ <h1>✅ Authorized!</h1>
111
+ <p>You can close this tab and return to your terminal.</p>
112
+ </div>
113
+ </body></html>
114
+ `);
115
+ srv.close();
116
+ resolve(v);
117
+ }
118
+ });
119
+ srv.listen(3456, () => {
120
+ console.log(" Waiting for authorization (listening on port 3456)...\n");
121
+ });
122
+ srv.on("error", (e) => {
123
+ if (e.code === "EADDRINUSE") {
124
+ console.error(" ❌ Port 3456 is in use. Close whatever is using it and try again.");
125
+ process.exit(1);
126
+ }
127
+ reject(e);
128
+ });
129
+ // Timeout after 5 minutes
130
+ setTimeout(() => { srv.close(); reject(new Error("Timeout — no authorization received after 5 minutes")); }, 300000);
131
+ });
132
+
133
+ console.log(" Step 3/3: Exchanging for access token...");
134
+
135
+ const accessTokenUrl = "https://api.x.com/oauth/access_token";
136
+ const accessAuth = sign("POST", accessTokenUrl, {
137
+ oauth_token: oauthToken,
138
+ oauth_verifier: verifier,
139
+ }, oauthTokenSecret);
140
+
141
+ const accessRes = await fetch(accessTokenUrl, {
142
+ method: "POST",
143
+ headers: { Authorization: accessAuth },
144
+ });
145
+
146
+ if (!accessRes.ok) {
147
+ const body = await accessRes.text();
148
+ console.error(`\n ❌ Failed to get access token: ${accessRes.status}\n ${body}`);
149
+ process.exit(1);
150
+ }
151
+
152
+ const accessBody = await accessRes.text();
153
+ const accessParams = new URLSearchParams(accessBody);
154
+ const accessToken = accessParams.get("oauth_token");
155
+ const accessTokenSecret = accessParams.get("oauth_token_secret");
156
+ const screenName = accessParams.get("screen_name");
157
+ const userId = accessParams.get("user_id");
158
+
159
+ console.log(`\n ✅ Success! Authorized as @${screenName} (ID: ${userId})\n`);
160
+ console.log(" ─────────────────────────────────────────────────");
161
+ console.log(" Add these to your Claude config env:\n");
162
+ console.log(` X_BEARER_TOKEN=${BEARER_TOKEN || "<ask your app owner for this>"}`);
163
+ console.log(` X_API_KEY=${API_KEY}`);
164
+ console.log(` X_API_SECRET=${API_SECRET}`);
165
+ console.log(` X_ACCESS_TOKEN=${accessToken}`);
166
+ console.log(` X_ACCESS_TOKEN_SECRET=${accessTokenSecret}`);
167
+ if (!BEARER_TOKEN) {
168
+ console.log("\n ⚠️ X_BEARER_TOKEN was not provided. Ask your app owner for it.");
169
+ }
170
+ console.log("\n ─────────────────────────────────────────────────");
171
+ console.log(" These tokens don't expire. Store them securely.\n");