Twitter MCP Server

  • src
#!/usr/bin/env node /** * This is a template MCP server that implements a simple notes system. * It demonstrates core MCP concepts like resources and tools by allowing: * - Listing notes as resources * - Reading individual notes * - Creating new notes via a tool * - Summarizing all notes via a prompt */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { Scraper } from 'agent-twitter-client'; import { Tweet, SearchMode, Profile } from 'agent-twitter-client'; /** * Type alias for a note object. */ type Note = { title: string, content: string }; /** * Simple in-memory storage for notes. * In a real implementation, this would likely be backed by a database. */ const notes: { [id: string]: Note } = { "1": { title: "First Note", content: "This is note 1" }, "2": { title: "Second Note", content: "This is note 2" } }; /** * Create an MCP server with capabilities for resources (to list/read notes), * tools (to create new notes), and prompts (to summarize notes). */ const server = new Server( { name: "twitter-mcp-server", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, prompts: {}, }, } ); // Global authenticated scraper instance let authenticatedScraper: Scraper | null = null; /** * Handler for listing available notes as resources. * Each note is exposed as a resource with: * - A note:// URI scheme * - Plain text MIME type * - Human readable name and description (now including the note title) */ server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: Object.entries(notes).map(([id, note]) => ({ uri: `note:///${id}`, mimeType: "text/plain", name: note.title, description: `A text note: ${note.title}` })) }; }); /** * Handler for reading the contents of a specific note. * Takes a note:// URI and returns the note content as plain text. */ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const url = new URL(request.params.uri); const id = url.pathname.replace(/^\//, ''); const note = notes[id]; if (!note) { throw new Error(`Note ${id} not found`); } return { contents: [{ uri: request.params.uri, mimeType: "text/plain", text: note.content }] }; }); /** * Handler that lists available tools. * Exposes a single "create_note" tool that lets clients create new notes. */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create_note", description: "Create a new note", inputSchema: { type: "object", properties: { title: { type: "string", description: "Title of the note" }, content: { type: "string", description: "Text content of the note" } }, required: ["title", "content"] } }, { name: "get_tweets", description: "Get recent tweets from a user", inputSchema: { type: "object", properties: { username: { type: "string", description: "Username of the user (without @)" }, count: { type: "number", description: "Number of tweets to retrieve (default: 10, max: 50)" } }, required: ["username"] } }, { name: "get_profile", description: "Get a Twitter user's profile information", inputSchema: { type: "object", properties: { username: { type: "string", description: "Username of the user (without @)" } }, required: ["username"] } }, { name: "search_tweets", description: "Search for tweets by hashtag or keyword", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query (hashtag or keyword). For hashtags, include the # symbol" }, mode: { type: "string", enum: ["latest", "top"], description: "Search mode - 'latest' for most recent tweets or 'top' for most relevant tweets", default: "latest" }, count: { type: "number", description: "Number of tweets to retrieve (default: 10, max: 50)" } }, required: ["query"] } }, { name: "like_tweet", description: "Like or unlike a tweet", inputSchema: { type: "object", properties: { tweet_id: { type: "string", description: "ID of the tweet to like/unlike" }, action: { type: "string", enum: ["like", "unlike"], description: "Whether to like or unlike the tweet", default: "like" } }, required: ["tweet_id"] } }, { name: "retweet", description: "Retweet or undo retweet of a tweet", inputSchema: { type: "object", properties: { tweet_id: { type: "string", description: "ID of the tweet to retweet/undo retweet" }, action: { type: "string", enum: ["retweet", "undo"], description: "Whether to retweet or undo the retweet", default: "retweet" } }, required: ["tweet_id"] } }, { name: "post_tweet", description: "Post a new tweet, optionally with media or as a quote tweet", inputSchema: { type: "object", properties: { text: { type: "string", description: "The text content of the tweet" }, reply_to_tweet_id: { type: "string", description: "Optional: ID of the tweet to reply to" }, quote_tweet_id: { type: "string", description: "Optional: ID of the tweet to quote" }, media: { type: "array", description: "Optional: Array of media items to attach to the tweet", items: { type: "object", properties: { data: { type: "string", description: "Base64 encoded media data" }, media_type: { type: "string", description: "MIME type of the media (e.g., 'image/jpeg', 'image/png', 'video/mp4')" } }, required: ["data", "media_type"] } }, hide_link_preview: { type: "boolean", description: "Optional: Whether to hide link previews in the tweet", default: false } }, required: ["text"] } }, { name: "get_trends", description: "Get current trending topics on Twitter", inputSchema: { type: "object", properties: {}, required: [] } }, { name: "get_user_relationships", description: "Get a user's followers or following list", inputSchema: { type: "object", properties: { username: { type: "string", description: "Username of the user (without @)" }, relationship_type: { type: "string", enum: ["followers", "following"], description: "Whether to get followers or following list", default: "followers" }, count: { type: "number", description: "Number of profiles to retrieve (default: 10, max: 50)" } }, required: ["username", "relationship_type"] } }, { name: "get_timeline", description: "Get tweets from a user's timeline or home timeline", inputSchema: { type: "object", properties: { timeline_type: { type: "string", enum: ["home", "following", "user"], description: "Type of timeline to fetch: 'home' for your personalized timeline, 'following' for tweets from people you follow, or 'user' for a specific user's timeline", default: "home" }, username: { type: "string", description: "Username of the user whose timeline to fetch (required only for timeline_type='user')" }, count: { type: "number", description: "Number of tweets to retrieve (default: 10, max: 50)" } }, required: ["timeline_type"] } }, { name: "get_list_tweets", description: "Get tweets from a Twitter list", inputSchema: { type: "object", properties: { list_id: { type: "string", description: "ID of the Twitter list to fetch tweets from" }, count: { type: "number", description: "Number of tweets to retrieve (default: 10, max: 50)" } }, required: ["list_id"] } }, { name: "follow_user", description: "Follow or unfollow a Twitter user", inputSchema: { type: "object", properties: { username: { type: "string", description: "Username of the user to follow/unfollow (without @)" }, action: { type: "string", enum: ["follow", "unfollow"], description: "Whether to follow or unfollow the user", default: "follow" } }, required: ["username"] } }, { name: "create_thread", description: "Create a Twitter thread (a series of connected tweets)", inputSchema: { type: "object", properties: { tweets: { type: "array", description: "Array of tweet objects containing the content for each tweet in the thread", items: { type: "object", properties: { text: { type: "string", description: "The text content of the tweet" }, media: { type: "array", description: "Optional: Array of media items to attach to the tweet", items: { type: "object", properties: { data: { type: "string", description: "Base64 encoded media data" }, media_type: { type: "string", description: "MIME type of the media (e.g., 'image/jpeg', 'image/png', 'video/mp4')" } }, required: ["data", "media_type"] } } }, required: ["text"] } } }, required: ["tweets"] } } ] }; }); /** * Handler for the create_note tool. * Creates a new note with the provided title and content, and returns success message. */ server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "create_note": { const title = String(request.params.arguments?.title); const content = String(request.params.arguments?.content); if (!title || !content) { return { content: [{ type: "text", text: "Error: Title and content are required" }] }; } const id = String(Object.keys(notes).length + 1); notes[id] = { title, content }; return { content: [{ type: "text", text: `Created note ${id}: ${title}` }] }; } case "get_tweets": { // Input validation if (!request.params.arguments?.username) { return { content: [{ type: "text", text: "Error: Username is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } // Clean up username (remove @ if present and any whitespace) const username = String(request.params.arguments.username).replace('@', '').trim(); const count = Math.min(Number(request.params.arguments?.count) || 10, 50); // Default to 10, max 50 const date = String(request.params.arguments?.date); try { // First get the user ID const userId = await authenticatedScraper.getUserIdByScreenName(username); // Then get tweets using the user ID const tweetIterator = authenticatedScraper.getUserTweetsIterator(userId, count); const tweets: Tweet[] = []; for await (const tweet of tweetIterator) { if (tweet.timeParsed) { if (date) { const tweetDate = new Date(tweet.timeParsed).toISOString().slice(0, 10); if (tweetDate === date) { tweets.push(tweet); } } else { tweets.push(tweet); } if (tweets.length >= count) break; } } // Format tweets for better readability const formattedTweets = tweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, created_at: tweet.timeParsed, metrics: { likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views }, urls: tweet.urls, hashtags: tweet.hashtags, is_retweet: tweet.isRetweet, is_reply: tweet.isReply })); return { content: [{ type: "text", text: formattedTweets.length > 0 ? JSON.stringify(formattedTweets, null, 2) : `No tweets found for user @${username}` }] }; } catch (error) { console.error('Error fetching tweets:', error); // Provide more specific error messages const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to fetch tweets. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "get_profile": { // Input validation if (!request.params.arguments?.username) { return { content: [{ type: "text", text: "Error: Username is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } // Clean up username (remove @ if present and any whitespace) const username = String(request.params.arguments.username).replace('@', '').trim(); try { const profile = await authenticatedScraper.getProfile(username); // Format profile for better readability const formattedProfile = { id: profile.userId, username: profile.username, displayName: profile.name, bio: profile.biography, location: profile.location, website: profile.website, joinDate: profile.joined, metrics: { tweets: profile.tweetsCount, followers: profile.followersCount, following: profile.followingCount, likes: profile.likesCount, listed: profile.listedCount }, isVerified: profile.isVerified, isBlueVerified: profile.isBlueVerified, isPrivate: profile.isPrivate, avatar: profile.avatar, banner: profile.banner }; return { content: [{ type: "text", text: JSON.stringify(formattedProfile, null, 2) }] }; } catch (error) { console.error('Error fetching profile:', error); // Provide more specific error messages const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to fetch profile. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "search_tweets": { // Input validation if (!request.params.arguments?.query) { return { content: [{ type: "text", text: "Error: Search query is required" }] }; } const query = String(request.params.arguments.query).trim(); const mode = (request.params.arguments?.mode || 'latest') as 'latest' | 'top'; const count = Math.min(Number(request.params.arguments?.count) || 10, 50); try { if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } // Convert mode string to SearchMode enum const searchMode = mode === 'latest' ? SearchMode.Latest : SearchMode.Top; // Search for tweets const tweetIterator = authenticatedScraper.searchTweets(query, count, searchMode); const tweets: Tweet[] = []; for await (const tweet of tweetIterator) { tweets.push(tweet); if (tweets.length >= count) break; } // Format tweets for better readability const formattedTweets = tweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, author: { id: tweet.userId, username: tweet.username, displayName: tweet.name }, created_at: tweet.timeParsed, metrics: { likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views }, urls: tweet.urls, hashtags: tweet.hashtags, is_retweet: tweet.isRetweet, is_reply: tweet.isReply })); return { content: [{ type: "text", text: formattedTweets.length > 0 ? JSON.stringify(formattedTweets, null, 2) : `No tweets found for query: ${query}` }] }; } catch (error) { console.error('Error searching tweets:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to search tweets. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "like_tweet": { // Input validation if (!request.params.arguments?.tweet_id) { return { content: [{ type: "text", text: "Error: Tweet ID is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const tweetId = String(request.params.arguments.tweet_id); const action = String(request.params.arguments?.action || 'like'); try { await authenticatedScraper.likeTweet(tweetId); return { content: [{ type: "text", text: `Successfully ${action === 'like' ? 'liked' : 'unliked'} tweet ${tweetId}` }] }; } catch (error) { console.error('Error liking/unliking tweet:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : `Error: Failed to ${action} tweet. Please try again later.`; return { content: [{ type: "text", text: errorMessage }] }; } } case "retweet": { // Input validation if (!request.params.arguments?.tweet_id) { return { content: [{ type: "text", text: "Error: Tweet ID is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const tweetId = String(request.params.arguments.tweet_id); const action = String(request.params.arguments?.action || 'retweet'); try { await authenticatedScraper.retweet(tweetId); return { content: [{ type: "text", text: `Successfully ${action === 'retweet' ? 'retweeted' : 'undid retweet'} tweet ${tweetId}` }] }; } catch (error) { console.error('Error retweeting/unretweeting tweet:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : `Error: Failed to ${action} tweet. Please try again later.`; return { content: [{ type: "text", text: errorMessage }] }; } } case "post_tweet": { // Input validation if (!request.params.arguments?.text) { return { content: [{ type: "text", text: "Error: Tweet text is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const text = String(request.params.arguments.text); const replyToTweetId = request.params.arguments?.reply_to_tweet_id ? String(request.params.arguments.reply_to_tweet_id) : undefined; const quoteTweetId = request.params.arguments?.quote_tweet_id ? String(request.params.arguments.quote_tweet_id) : undefined; try { let mediaData: { data: Buffer; mediaType: string }[] | undefined; // Process media if provided if (request.params.arguments?.media && Array.isArray(request.params.arguments.media)) { mediaData = request.params.arguments.media.map((item: { data: string; media_type: string }) => ({ data: Buffer.from(String(item.data), 'base64'), mediaType: String(item.media_type) })); } let result; if (quoteTweetId) { // Quote tweet result = await authenticatedScraper.sendTweet( text, quoteTweetId, mediaData ); if (!result.ok) { throw new Error(`Failed to create quote tweet: ${await result.text()}`); } } else { // Regular tweet or reply result = await authenticatedScraper.sendTweet( text, replyToTweetId, mediaData ); if (!result.ok) { throw new Error(`Failed to create tweet: ${await result.text()}`); } } return { content: [{ type: "text", text: quoteTweetId ? `Successfully posted quote tweet` : replyToTweetId ? `Successfully posted reply to tweet ${replyToTweetId}` : `Successfully posted tweet` }] }; } catch (error) { console.error('Error posting tweet:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to post tweet. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "get_trends": { if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } try { const trends = await authenticatedScraper.getTrends(); return { content: [{ type: "text", text: trends.length > 0 ? JSON.stringify(trends, null, 2) : "No trending topics found at the moment" }] }; } catch (error) { console.error('Error fetching trends:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to fetch trending topics. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "get_user_relationships": { // Input validation if (!request.params.arguments?.username) { return { content: [{ type: "text", text: "Error: Username is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } // Clean up username and get parameters const username = String(request.params.arguments.username).replace('@', '').trim(); const relationshipType = String(request.params.arguments?.relationship_type || 'followers'); const count = Math.min(Number(request.params.arguments?.count) || 10, 50); // Default to 10, max 50 try { // First get the user ID const userId = await authenticatedScraper.getUserIdByScreenName(username); // Get the profiles based on relationship type const profileIterator = relationshipType === 'followers' ? authenticatedScraper.getFollowers(userId, count) : authenticatedScraper.getFollowing(userId, count); const profiles: Profile[] = []; for await (const profile of profileIterator) { profiles.push(profile); if (profiles.length >= count) break; } // Format profiles for better readability const formattedProfiles = profiles.map(profile => ({ id: profile.userId, username: profile.username, displayName: profile.name, bio: profile.biography, metrics: { tweets: profile.tweetsCount, followers: profile.followersCount, following: profile.followingCount }, isVerified: profile.isVerified, isPrivate: profile.isPrivate, avatar: profile.avatar })); return { content: [{ type: "text", text: formattedProfiles.length > 0 ? JSON.stringify(formattedProfiles, null, 2) : `No ${relationshipType} found for user @${username}` }] }; } catch (error) { console.error(`Error fetching ${relationshipType}:`, error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : `Error: Failed to fetch ${relationshipType}. Please try again later.`; return { content: [{ type: "text", text: errorMessage }] }; } } case "get_timeline": { // Input validation if (!request.params.arguments?.timeline_type) { return { content: [{ type: "text", text: "Error: Timeline type is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const timelineType = String(request.params.arguments.timeline_type); const username = request.params.arguments?.username ? String(request.params.arguments.username).replace('@', '').trim() : undefined; const count = Math.min(Number(request.params.arguments?.count) || 10, 50); try { let tweets: Tweet[] = []; if (timelineType === 'home') { // For home timeline, we don't need seen tweet IDs for first fetch const homeTimelineTweets = await authenticatedScraper.fetchHomeTimeline(count, []); tweets = homeTimelineTweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, userId: tweet.userId, username: tweet.username, name: tweet.name, timeParsed: tweet.timeParsed, likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views, urls: tweet.urls || [], hashtags: tweet.hashtags || [], mentions: tweet.mentions || [], photos: tweet.photos || [], videos: tweet.videos || [], thread: tweet.thread || [], isRetweet: tweet.isRetweet || false, isReply: tweet.isReply || false })); } else if (timelineType === 'following') { // For following timeline, we don't need seen tweet IDs for first fetch const followingTimelineTweets = await authenticatedScraper.fetchFollowingTimeline(count, []); tweets = followingTimelineTweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, userId: tweet.userId, username: tweet.username, name: tweet.name, timeParsed: tweet.timeParsed, likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views, urls: tweet.urls || [], hashtags: tweet.hashtags || [], mentions: tweet.mentions || [], photos: tweet.photos || [], videos: tweet.videos || [], thread: tweet.thread || [], isRetweet: tweet.isRetweet || false, isReply: tweet.isReply || false })); } else if (timelineType === 'user') { if (!username) { return { content: [{ type: "text", text: "Error: Username is required for user timeline" }] }; } // First get the user ID const userId = await authenticatedScraper.getUserIdByScreenName(username); // Then get tweets using the user ID const tweetIterator = authenticatedScraper.getUserTweetsIterator(userId, count); for await (const tweet of tweetIterator) { tweets.push(tweet); if (tweets.length >= count) break; } } else { return { content: [{ type: "text", text: "Error: Invalid timeline type" }] }; } // Format tweets for better readability const formattedTweets = tweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, author: { id: tweet.userId, username: tweet.username, displayName: tweet.name }, created_at: tweet.timeParsed, metrics: { likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views }, urls: tweet.urls, hashtags: tweet.hashtags, is_retweet: tweet.isRetweet, is_reply: tweet.isReply })); return { content: [{ type: "text", text: formattedTweets.length > 0 ? JSON.stringify(formattedTweets, null, 2) : `No tweets found for ${timelineType} timeline${username ? ` of user @${username}` : ''}` }] }; } catch (error) { console.error('Error fetching timeline:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to fetch timeline. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "get_list_tweets": { // Input validation if (!request.params.arguments?.list_id) { return { content: [{ type: "text", text: "Error: List ID is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const listId = String(request.params.arguments.list_id); const count = Math.min(Number(request.params.arguments?.count) || 10, 50); try { const response = await authenticatedScraper.fetchListTweets(listId, count); const tweets = response.tweets; // Format tweets for better readability const formattedTweets = tweets.map((tweet: Tweet) => ({ id: tweet.id, text: tweet.text, author: { id: tweet.userId, username: tweet.username, displayName: tweet.name }, created_at: tweet.timeParsed, metrics: { likes: tweet.likes, retweets: tweet.retweets, replies: tweet.replies, views: tweet.views }, urls: tweet.urls, hashtags: tweet.hashtags, is_retweet: tweet.isRetweet, is_reply: tweet.isReply })); return { content: [{ type: "text", text: formattedTweets.length > 0 ? JSON.stringify(formattedTweets, null, 2) : `No tweets found for list ${listId}` }] }; } catch (error) { console.error('Error fetching list tweets:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to fetch list tweets. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } case "follow_user": { // Input validation if (!request.params.arguments?.username) { return { content: [{ type: "text", text: "Error: Username is required" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } const username = String(request.params.arguments.username).replace('@', '').trim(); const action = String(request.params.arguments?.action || 'follow'); try { // Get user ID from username const userId = await authenticatedScraper.getUserIdByScreenName(username); // Prepare the request body const requestBody = { include_profile_interstitial_type: '1', skip_status: 'true', user_id: userId }; // Prepare the headers const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': `https://twitter.com/${username}`, 'X-Twitter-Active-User': 'yes', 'X-Twitter-Auth-Type': 'OAuth2Session', 'X-Twitter-Client-Language': 'en' }); // Install auth headers await (authenticatedScraper as any).auth.installTo( headers, action === 'follow' ? 'https://api.twitter.com/1.1/friendships/create.json' : 'https://api.twitter.com/1.1/friendships/destroy.json' ); // Make the follow/unfollow request const res = await (authenticatedScraper as any).auth.fetch( action === 'follow' ? 'https://api.twitter.com/1.1/friendships/create.json' : 'https://api.twitter.com/1.1/friendships/destroy.json', { method: 'POST', headers, body: new URLSearchParams(requestBody).toString(), credentials: 'include' } ); if (!res.ok) { throw new Error(`Failed to ${action} user: ${res.statusText}`); } return { content: [{ type: "text", text: `Successfully ${action === 'follow' ? 'followed' : 'unfollowed'} user @${username}` }] }; } catch (error) { console.error(`Error ${action}ing user:`, error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : `Error: Failed to ${action} user. Please try again later.`; return { content: [{ type: "text", text: errorMessage }] }; } } case "create_thread": { // Input validation if (!request.params.arguments?.tweets || !Array.isArray(request.params.arguments.tweets)) { return { content: [{ type: "text", text: "Error: tweets array is required" }] }; } if (request.params.arguments.tweets.length < 2) { return { content: [{ type: "text", text: "Error: A thread must contain at least 2 tweets" }] }; } if (!authenticatedScraper) { return { content: [{ type: "text", text: "Error: Twitter API is not properly initialized. Please check the server logs." }] }; } try { const tweets = request.params.arguments.tweets; let previousTweetId: string | undefined; const threadTweetIds: string[] = []; // Post each tweet in the thread for (const tweet of tweets) { // Process media if provided let mediaData: { data: Buffer; mediaType: string }[] | undefined; if (tweet.media && Array.isArray(tweet.media)) { mediaData = tweet.media.map((item: { data: string; media_type: string }) => ({ data: Buffer.from(String(item.data), 'base64'), mediaType: String(item.media_type) })); } // Send the tweet as a reply to the previous tweet const result = await authenticatedScraper.sendTweet( tweet.text, previousTweetId, mediaData ); if (!result.ok) { throw new Error(`Failed to create tweet in thread: ${await result.text()}`); } // Extract the tweet ID from the response const responseData = await result.json(); const tweetId = responseData?.data?.create_tweet?.tweet_results?.result?.rest_id; if (!tweetId) { throw new Error('Failed to get tweet ID from response'); } // Store the tweet ID for the next iteration previousTweetId = tweetId; threadTweetIds.push(tweetId); } return { content: [{ type: "text", text: `Successfully created thread with ${threadTweetIds.length} tweets. First tweet ID: ${threadTweetIds[0]}` }] }; } catch (error) { console.error('Error creating thread:', error); const errorMessage = error instanceof Error ? `Error: ${error.message}` : 'Error: Failed to create thread. Please try again later.'; return { content: [{ type: "text", text: errorMessage }] }; } } default: return { content: [{ type: "text", text: "Error: Unknown tool" }] }; } }); async function main() { try { // Initialize the authenticated scraper authenticatedScraper = new Scraper(); // Try username/password login first try { await authenticatedScraper.login( process.env.TWITTER_USERNAME || "", process.env.TWITTER_PASSWORD || "", process.env.TWITTER_EMAIL ); console.log("Successfully authenticated with Twitter username/password"); } catch (loginError) { console.log("Username/password login failed, falling back to API keys..."); // Fall back to API key authentication await authenticatedScraper.login( "", "", undefined, undefined, process.env.TWITTER_API_KEY, process.env.TWITTER_API_SECRET_KEY, process.env.TWITTER_ACCESS_TOKEN, process.env.TWITTER_ACCESS_TOKEN_SECRET ); console.log("Successfully authenticated with Twitter API keys"); } } catch (error) { console.error("Failed to authenticate with Twitter:", error); process.exit(1); } const transport = new StdioServerTransport(); await server.connect(transport); console.log("Twitter MCP server running on stdio"); } main().catch((error) => { console.error("Server error:", error); process.exit(1); });