@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.
- package/README.md +128 -37
- package/index.js +337 -17
- package/package.json +3 -3
- package/setup.js +171 -0
package/README.md
CHANGED
|
@@ -1,38 +1,65 @@
|
|
|
1
1
|
# x-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@realaman90/x-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
|
19
|
-
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
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
|
|
53
|
+
### 1. Get X API credentials
|
|
27
54
|
|
|
28
|
-
1. Go to [developer.x.com](https://developer.x.com)
|
|
29
|
-
2.
|
|
30
|
-
3.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
87
|
+
Or add to **Claude Desktop**: **Settings** → **Developer** → **Edit Config** → same block.
|
|
57
88
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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. `
|
|
151
|
+
3. `like_post` → like a specific tweet
|
|
152
|
+
4. `create_post` → reply to it
|
|
82
153
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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 (~
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
4
|
-
"description": "MCP server for X/Twitter API —
|
|
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");
|