xbird-mcp 0.1.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.

Potentially problematic release.


This version of xbird-mcp might be problematic. Click here for more details.

Files changed (85) hide show
  1. package/docs/listing.md +42 -0
  2. package/docs/mcp-setup.md +146 -0
  3. package/package.json +50 -0
  4. package/src/cli/context.ts +80 -0
  5. package/src/cli/output.ts +33 -0
  6. package/src/cli/program.ts +63 -0
  7. package/src/cli.ts +8 -0
  8. package/src/commands/about.ts +30 -0
  9. package/src/commands/bookmark.ts +40 -0
  10. package/src/commands/bookmarks-list.ts +108 -0
  11. package/src/commands/check.ts +27 -0
  12. package/src/commands/engagement.ts +31 -0
  13. package/src/commands/follow.ts +40 -0
  14. package/src/commands/followers.ts +132 -0
  15. package/src/commands/home.ts +59 -0
  16. package/src/commands/likes.ts +73 -0
  17. package/src/commands/lists.ts +101 -0
  18. package/src/commands/mentions.ts +32 -0
  19. package/src/commands/news.ts +38 -0
  20. package/src/commands/read.ts +36 -0
  21. package/src/commands/replies.ts +59 -0
  22. package/src/commands/search.ts +60 -0
  23. package/src/commands/tweet.ts +128 -0
  24. package/src/commands/user-tweets.ts +73 -0
  25. package/src/commands/user.ts +20 -0
  26. package/src/commands/whoami.ts +26 -0
  27. package/src/formatters/common.ts +49 -0
  28. package/src/formatters/list.ts +22 -0
  29. package/src/formatters/news.ts +23 -0
  30. package/src/formatters/tweet.ts +38 -0
  31. package/src/formatters/user.ts +20 -0
  32. package/src/index.ts +26 -0
  33. package/src/lib/auth.ts +105 -0
  34. package/src/lib/client-bookmarks.ts +62 -0
  35. package/src/lib/client-engagement.ts +152 -0
  36. package/src/lib/client-follow.ts +90 -0
  37. package/src/lib/client-full.ts +48 -0
  38. package/src/lib/client-likes.ts +62 -0
  39. package/src/lib/client-lists.ts +227 -0
  40. package/src/lib/client-media.ts +162 -0
  41. package/src/lib/client-news.ts +185 -0
  42. package/src/lib/client-posting.ts +163 -0
  43. package/src/lib/client-reading.ts +452 -0
  44. package/src/lib/client-search.ts +156 -0
  45. package/src/lib/client-timeline.ts +98 -0
  46. package/src/lib/client-user.ts +518 -0
  47. package/src/lib/client.ts +134 -0
  48. package/src/lib/config.ts +22 -0
  49. package/src/lib/constants.ts +55 -0
  50. package/src/lib/cookies.ts +132 -0
  51. package/src/lib/features.ts +175 -0
  52. package/src/lib/headers.ts +28 -0
  53. package/src/lib/paginate.ts +39 -0
  54. package/src/lib/query-ids.ts +190 -0
  55. package/src/lib/types.ts +147 -0
  56. package/src/lib/utils.ts +176 -0
  57. package/src/mcp/executor.ts +178 -0
  58. package/src/mcp/payment.ts +38 -0
  59. package/src/mcp/server.ts +53 -0
  60. package/src/mcp/tools.ts +389 -0
  61. package/src/server/app.ts +117 -0
  62. package/src/server/config/accounts.ts +137 -0
  63. package/src/server/config/pricing.ts +217 -0
  64. package/src/server/erc8004/register.ts +77 -0
  65. package/src/server/middleware/account-pool.ts +101 -0
  66. package/src/server/middleware/error-handler.ts +27 -0
  67. package/src/server/middleware/payer-extract.ts +43 -0
  68. package/src/server/middleware/x402.ts +61 -0
  69. package/src/server/routes/accounts.ts +93 -0
  70. package/src/server/routes/authorize.ts +19 -0
  71. package/src/server/routes/bookmarks.ts +21 -0
  72. package/src/server/routes/engagement.ts +84 -0
  73. package/src/server/routes/follow.ts +32 -0
  74. package/src/server/routes/health.ts +14 -0
  75. package/src/server/routes/lists.ts +38 -0
  76. package/src/server/routes/media.ts +44 -0
  77. package/src/server/routes/mentions.ts +20 -0
  78. package/src/server/routes/news.ts +23 -0
  79. package/src/server/routes/search.ts +26 -0
  80. package/src/server/routes/timeline.ts +21 -0
  81. package/src/server/routes/tweets.ts +82 -0
  82. package/src/server/routes/users.ts +92 -0
  83. package/src/server/storage/accounts-db.ts +84 -0
  84. package/src/server.ts +34 -0
  85. package/tsconfig.json +29 -0
@@ -0,0 +1,162 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { TWITTER_UPLOAD_URL } from "./constants.ts";
3
+ import { buildBaseHeaders } from "./headers.ts";
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
7
+
8
+ const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
9
+ const MAX_STATUS_POLLS = 20;
10
+
11
+ export function withMedia<T extends Constructor>(Base: T) {
12
+ return class extends Base {
13
+ async uploadMedia(opts: {
14
+ data: Uint8Array | Buffer;
15
+ mimeType: string;
16
+ alt?: string;
17
+ }): Promise<{ success: boolean; mediaId?: string; error?: string }> {
18
+ try {
19
+ const headers = buildBaseHeaders(this.credentials);
20
+ const totalBytes = opts.data.length;
21
+
22
+ // Determine media category from mime type
23
+ const category = opts.mimeType.startsWith("video/")
24
+ ? "tweet_video"
25
+ : opts.mimeType === "image/gif"
26
+ ? "tweet_gif"
27
+ : "tweet_image";
28
+
29
+ // INIT
30
+ const initParams = new URLSearchParams({
31
+ command: "INIT",
32
+ total_bytes: String(totalBytes),
33
+ media_type: opts.mimeType,
34
+ media_category: category,
35
+ });
36
+
37
+ const initRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
38
+ method: "POST",
39
+ headers: { ...headers, "content-type": "application/x-www-form-urlencoded" },
40
+ body: initParams.toString(),
41
+ });
42
+
43
+ if (!initRes.ok) {
44
+ const text = await initRes.text();
45
+ return { success: false, error: `INIT failed: HTTP ${initRes.status}: ${text.slice(0, 200)}` };
46
+ }
47
+
48
+ const initJson = (await initRes.json()) as Record<string, unknown>;
49
+ const mediaId = String(initJson.media_id_string ?? initJson.media_id ?? "");
50
+
51
+ if (!mediaId) {
52
+ return { success: false, error: "No media_id in INIT response" };
53
+ }
54
+
55
+ // APPEND — upload in chunks
56
+ let segmentIndex = 0;
57
+ for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
58
+ const chunk = opts.data.slice(offset, offset + CHUNK_SIZE);
59
+ const formData = new FormData();
60
+ formData.append("command", "APPEND");
61
+ formData.append("media_id", mediaId);
62
+ formData.append("segment_index", String(segmentIndex));
63
+ formData.append("media", new Blob([chunk], { type: opts.mimeType }));
64
+
65
+ const appendRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
66
+ method: "POST",
67
+ headers,
68
+ body: formData,
69
+ });
70
+
71
+ if (!appendRes.ok) {
72
+ const text = await appendRes.text();
73
+ return { success: false, error: `APPEND failed (segment ${segmentIndex}): HTTP ${appendRes.status}: ${text.slice(0, 200)}` };
74
+ }
75
+
76
+ segmentIndex++;
77
+ }
78
+
79
+ // FINALIZE
80
+ const finalizeParams = new URLSearchParams({
81
+ command: "FINALIZE",
82
+ media_id: mediaId,
83
+ });
84
+
85
+ const finalizeRes = await this.fetchWithTimeout(TWITTER_UPLOAD_URL, {
86
+ method: "POST",
87
+ headers: { ...headers, "content-type": "application/x-www-form-urlencoded" },
88
+ body: finalizeParams.toString(),
89
+ });
90
+
91
+ if (!finalizeRes.ok) {
92
+ const text = await finalizeRes.text();
93
+ return { success: false, error: `FINALIZE failed: HTTP ${finalizeRes.status}: ${text.slice(0, 200)}` };
94
+ }
95
+
96
+ const finalizeJson = (await finalizeRes.json()) as Record<string, unknown>;
97
+ const processingInfo = finalizeJson.processing_info as Record<string, unknown> | undefined;
98
+
99
+ // Poll STATUS if processing is required (videos)
100
+ if (processingInfo) {
101
+ let state = String(processingInfo.state ?? "");
102
+ let checkAfterMs = Number(processingInfo.check_after_secs ?? 5) * 1000;
103
+ let polls = 0;
104
+
105
+ while (state !== "succeeded" && state !== "failed" && polls < MAX_STATUS_POLLS) {
106
+ await new Promise((r) => setTimeout(r, checkAfterMs));
107
+ polls++;
108
+
109
+ const statusUrl = `${TWITTER_UPLOAD_URL}?command=STATUS&media_id=${mediaId}`;
110
+ const statusRes = await this.fetchWithTimeout(statusUrl, {
111
+ method: "GET",
112
+ headers,
113
+ });
114
+
115
+ if (!statusRes.ok) {
116
+ return { success: false, error: `STATUS poll failed: HTTP ${statusRes.status}` };
117
+ }
118
+
119
+ const statusJson = (await statusRes.json()) as Record<string, unknown>;
120
+ const info = statusJson.processing_info as Record<string, unknown> | undefined;
121
+ state = String(info?.state ?? "succeeded");
122
+ checkAfterMs = Number(info?.check_after_secs ?? 5) * 1000;
123
+
124
+ if (state === "failed") {
125
+ const errorInfo = info?.error as Record<string, unknown> | undefined;
126
+ return {
127
+ success: false,
128
+ error: `Media processing failed: ${errorInfo?.message ?? "unknown error"}`,
129
+ };
130
+ }
131
+ }
132
+ }
133
+
134
+ // Set alt text if provided
135
+ if (opts.alt) {
136
+ const altRes = await this.fetchWithTimeout(
137
+ "https://x.com/i/api/1.1/media/metadata/create.json",
138
+ {
139
+ method: "POST",
140
+ headers: { ...headers, "content-type": "application/json" },
141
+ body: JSON.stringify({
142
+ media_id: mediaId,
143
+ alt_text: { text: opts.alt },
144
+ }),
145
+ }
146
+ );
147
+ // Alt text failure is non-fatal
148
+ if (!altRes.ok) {
149
+ console.error(`Warning: Failed to set alt text: HTTP ${altRes.status}`);
150
+ }
151
+ }
152
+
153
+ return { success: true, mediaId };
154
+ } catch (err) {
155
+ return {
156
+ success: false,
157
+ error: err instanceof Error ? err.message : String(err),
158
+ };
159
+ }
160
+ }
161
+ };
162
+ }
@@ -0,0 +1,185 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { newsFeatures } from "./features.ts";
3
+ import { TWITTER_V1_BASE } from "./constants.ts";
4
+ import type { NewsItem } from "./types.ts";
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
8
+
9
+ /** Timeline IDs for GenericTimelineById GraphQL */
10
+ const TIMELINE_IDS: Record<string, string> = {
11
+ trending: "trends_home",
12
+ forYou: "trending",
13
+ news: "news",
14
+ sports: "sports",
15
+ entertainment: "entertainment",
16
+ };
17
+
18
+ function parseNewsFromGraphQL(instructions: unknown[]): NewsItem[] {
19
+ const items: NewsItem[] = [];
20
+
21
+ for (const inst of instructions ?? []) {
22
+ const instruction = inst as Record<string, unknown>;
23
+ const entries = (instruction.entries ?? []) as Array<Record<string, unknown>>;
24
+
25
+ for (const entry of entries) {
26
+ const content = entry.content as Record<string, unknown> | undefined;
27
+ const itemContent = content?.itemContent as Record<string, unknown> | undefined;
28
+
29
+ if (itemContent) {
30
+ const trend = itemContent.trend as Record<string, unknown> | undefined;
31
+ const name = trend?.name as string | undefined
32
+ ?? itemContent.name as string | undefined
33
+ ?? itemContent.title as string | undefined;
34
+
35
+ if (name) {
36
+ const socialContext = itemContent.socialContext as Record<string, unknown> | undefined
37
+ ?? trend?.trendMetadata as Record<string, unknown> | undefined;
38
+
39
+ items.push({
40
+ id: String(entry.entryId ?? items.length),
41
+ headline: name,
42
+ category: String(socialContext?.text ?? socialContext?.domainContext ?? "Trending"),
43
+ postCount: socialContext?.metaDescription ? parsePostCount(String(socialContext.metaDescription)) : undefined,
44
+ description: socialContext?.metaDescription as string | undefined,
45
+ });
46
+ }
47
+ }
48
+
49
+ // Module items (grouped trends)
50
+ const moduleItems = content?.items as Array<Record<string, unknown>> | undefined;
51
+ if (moduleItems) {
52
+ for (const moduleItem of moduleItems) {
53
+ const modContent = (moduleItem.item as Record<string, unknown>)?.itemContent as Record<string, unknown> | undefined;
54
+ const trend = modContent?.trend as Record<string, unknown> | undefined;
55
+ const name = trend?.name as string | undefined ?? modContent?.name as string | undefined;
56
+
57
+ if (name) {
58
+ const meta = trend?.trendMetadata as Record<string, unknown> | undefined;
59
+ items.push({
60
+ id: String((moduleItem.item as Record<string, unknown>)?.entryId ?? items.length),
61
+ headline: name,
62
+ category: String(meta?.domainContext ?? "Trending"),
63
+ postCount: meta?.metaDescription ? parsePostCount(String(meta.metaDescription)) : undefined,
64
+ description: meta?.metaDescription as string | undefined,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ return items;
73
+ }
74
+
75
+ function parsePostCount(text: string): number | undefined {
76
+ const match = text.match(/([\d,.]+)\s*([KkMm])?/);
77
+ if (!match) return undefined;
78
+ let num = parseFloat(match[1]!.replace(/,/g, ""));
79
+ const suffix = match[2]?.toUpperCase();
80
+ if (suffix === "K") num *= 1_000;
81
+ if (suffix === "M") num *= 1_000_000;
82
+ return Math.round(num);
83
+ }
84
+
85
+ export function withNews<T extends Constructor>(Base: T) {
86
+ return class extends Base {
87
+ async getNews(
88
+ count?: number,
89
+ opts?: {
90
+ tab?: string;
91
+ aiOnly?: boolean;
92
+ withTweets?: boolean;
93
+ trendingOnly?: boolean;
94
+ }
95
+ ): Promise<{
96
+ success: boolean;
97
+ items: NewsItem[];
98
+ error?: string;
99
+ }> {
100
+ const tab = opts?.trendingOnly ? "trending" : (opts?.tab ?? "trending");
101
+
102
+ // Try GraphQL first
103
+ try {
104
+ const timelineId = TIMELINE_IDS[tab] ?? TIMELINE_IDS.trending!;
105
+
106
+ const { data, errors } = await this.graphqlGet(
107
+ "GenericTimelineById",
108
+ {
109
+ timelineId,
110
+ count: count ?? 20,
111
+ withQuickPromoteEligibilityTweetFields: true,
112
+ },
113
+ newsFeatures()
114
+ );
115
+
116
+ if (!errors?.length && data) {
117
+ const timeline = (data as Record<string, unknown>)?.timeline_by_id as Record<string, unknown> | undefined
118
+ ?? (data as Record<string, unknown>)?.timeline as Record<string, unknown> | undefined;
119
+ const tl = timeline?.timeline as Record<string, unknown> | undefined ?? timeline;
120
+ const instructions = tl?.instructions as unknown[] | undefined;
121
+
122
+ if (instructions) {
123
+ let items = parseNewsFromGraphQL(instructions);
124
+ if (opts?.aiOnly) {
125
+ items = items.filter(item => item.description && item.description.length > 0);
126
+ }
127
+ if (items.length > 0) return { success: true, items };
128
+ }
129
+ }
130
+ } catch {
131
+ // Fall through to REST
132
+ }
133
+
134
+ // Fallback: REST v1.1 trends/place.json
135
+ const result = await this._getNewsREST(count);
136
+ if (opts?.aiOnly && result.success) {
137
+ result.items = result.items.filter(item => item.description && item.description.length > 0);
138
+ }
139
+ return result;
140
+ }
141
+
142
+ private async _getNewsREST(
143
+ count?: number
144
+ ): Promise<{
145
+ success: boolean;
146
+ items: NewsItem[];
147
+ error?: string;
148
+ }> {
149
+ try {
150
+ const url = `${TWITTER_V1_BASE}/trends/place.json?id=1`;
151
+ const res = await this.fetchWithTimeout(url, {
152
+ method: "GET",
153
+ headers: this.getHeaders(),
154
+ });
155
+
156
+ if (!res.ok) {
157
+ return { success: false, items: [], error: `HTTP ${res.status}` };
158
+ }
159
+
160
+ const json = (await res.json()) as Array<Record<string, unknown>>;
161
+ const trendsData = json[0] as Record<string, unknown> | undefined;
162
+ const trends = trendsData?.trends as Array<Record<string, unknown>> | undefined;
163
+
164
+ if (!trends) return { success: true, items: [] };
165
+
166
+ const maxItems = count ?? 20;
167
+ const items: NewsItem[] = trends.slice(0, maxItems).map((t, i) => ({
168
+ id: String(i),
169
+ headline: String(t.name ?? ""),
170
+ category: "Trending",
171
+ postCount: t.tweet_volume != null ? Number(t.tweet_volume) : undefined,
172
+ url: t.url ? String(t.url) : undefined,
173
+ }));
174
+
175
+ return { success: true, items };
176
+ } catch (err) {
177
+ return {
178
+ success: false,
179
+ items: [],
180
+ error: err instanceof Error ? err.message : String(err),
181
+ };
182
+ }
183
+ }
184
+ };
185
+ }
@@ -0,0 +1,163 @@
1
+ import { TwitterClient } from "./client.ts";
2
+ import { createTweetFeatures } from "./features.ts";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ type Constructor<T = TwitterClient> = new (...args: any[]) => T;
6
+
7
+ export function withPosting<T extends Constructor>(Base: T) {
8
+ return class extends Base {
9
+ /**
10
+ * Post a new tweet, optionally with media.
11
+ */
12
+ async tweet(
13
+ text: string,
14
+ mediaIds?: string[]
15
+ ): Promise<{ success: boolean; tweetId?: string; error?: string }> {
16
+ try {
17
+ const mediaEntities = (mediaIds ?? []).map((id) => ({
18
+ media_id: id,
19
+ tagged_users: [],
20
+ }));
21
+
22
+ const { data, errors } = await this.graphqlPost(
23
+ "CreateTweet",
24
+ {
25
+ tweet_text: text,
26
+ dark_request: false,
27
+ media: {
28
+ media_entities: mediaEntities,
29
+ possibly_sensitive: false,
30
+ },
31
+ semantic_annotation_ids: [],
32
+ },
33
+ createTweetFeatures()
34
+ );
35
+
36
+ if (errors?.length) {
37
+ return { success: false, error: this.formatErrors(errors) };
38
+ }
39
+
40
+ const createTweet = (data as Record<string, unknown>)
41
+ ?.create_tweet as Record<string, unknown> | undefined;
42
+ const tweetResults = createTweet?.tweet_results as
43
+ | Record<string, unknown>
44
+ | undefined;
45
+ const result = tweetResults?.result as
46
+ | Record<string, unknown>
47
+ | undefined;
48
+ const tweetId = result?.rest_id as string | undefined;
49
+
50
+ if (!tweetId) {
51
+ return { success: false, error: "No tweet ID in response" };
52
+ }
53
+
54
+ return { success: true, tweetId };
55
+ } catch (err) {
56
+ return {
57
+ success: false,
58
+ error: err instanceof Error ? err.message : String(err),
59
+ };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Reply to an existing tweet, optionally with media.
65
+ */
66
+ async reply(
67
+ text: string,
68
+ replyToId: string,
69
+ mediaIds?: string[]
70
+ ): Promise<{ success: boolean; tweetId?: string; error?: string }> {
71
+ try {
72
+ const mediaEntities = (mediaIds ?? []).map((id) => ({
73
+ media_id: id,
74
+ tagged_users: [],
75
+ }));
76
+
77
+ const { data, errors } = await this.graphqlPost(
78
+ "CreateTweet",
79
+ {
80
+ tweet_text: text,
81
+ dark_request: false,
82
+ media: {
83
+ media_entities: mediaEntities,
84
+ possibly_sensitive: false,
85
+ },
86
+ semantic_annotation_ids: [],
87
+ reply: {
88
+ in_reply_to_tweet_id: replyToId,
89
+ exclude_reply_user_ids: [],
90
+ },
91
+ },
92
+ createTweetFeatures()
93
+ );
94
+
95
+ if (errors?.length) {
96
+ return { success: false, error: this.formatErrors(errors) };
97
+ }
98
+
99
+ const createTweet = (data as Record<string, unknown>)
100
+ ?.create_tweet as Record<string, unknown> | undefined;
101
+ const tweetResults = createTweet?.tweet_results as
102
+ | Record<string, unknown>
103
+ | undefined;
104
+ const result = tweetResults?.result as
105
+ | Record<string, unknown>
106
+ | undefined;
107
+ const tweetId = result?.rest_id as string | undefined;
108
+
109
+ if (!tweetId) {
110
+ return { success: false, error: "No tweet ID in response" };
111
+ }
112
+
113
+ return { success: true, tweetId };
114
+ } catch (err) {
115
+ return {
116
+ success: false,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Post a thread (multiple tweets in sequence).
124
+ * Returns all tweet IDs in order.
125
+ */
126
+ async thread(
127
+ texts: string[]
128
+ ): Promise<{ success: boolean; tweetIds: string[]; error?: string }> {
129
+ if (texts.length === 0) {
130
+ return { success: false, tweetIds: [], error: "No texts provided" };
131
+ }
132
+
133
+ const tweetIds: string[] = [];
134
+
135
+ // Post the first tweet
136
+ const first = await this.tweet(texts[0]!);
137
+ if (!first.success || !first.tweetId) {
138
+ return {
139
+ success: false,
140
+ tweetIds,
141
+ error: first.error ?? "Failed to post first tweet",
142
+ };
143
+ }
144
+ tweetIds.push(first.tweetId);
145
+
146
+ // Post subsequent tweets as replies
147
+ for (let i = 1; i < texts.length; i++) {
148
+ const previousId = tweetIds[tweetIds.length - 1]!;
149
+ const result = await this.reply(texts[i]!, previousId);
150
+ if (!result.success || !result.tweetId) {
151
+ return {
152
+ success: false,
153
+ tweetIds,
154
+ error: result.error ?? `Failed to post tweet ${i + 1} in thread`,
155
+ };
156
+ }
157
+ tweetIds.push(result.tweetId);
158
+ }
159
+
160
+ return { success: true, tweetIds };
161
+ }
162
+ };
163
+ }