# Directory Structure ``` ├── .gitignore ├── Dockerfile ├── LICENSE ├── pyproject.toml ├── README.md ├── smithery.yaml └── src └── mcp_twikit ├── __init__.py └── twitter.py ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | .venv/ 2 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP-Twikit 2 | 3 | [](https://smithery.ai/server/mcp-twikit) 4 | A Model Context Protocol (MCP) server for interacting with Twitter. 5 | 6 | <a href="https://glama.ai/mcp/servers/49i9dd08w8"><img width="380" height="200" src="https://glama.ai/mcp/servers/49i9dd08w8/badge" alt="mcp-twikit MCP server" /></a> 7 | 8 | ## Installation 9 | 10 | ### Installing via Smithery 11 | 12 | To install Twikit Twitter Search for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-twikit): 13 | 14 | ```bash 15 | npx -y @smithery/cli install mcp-twikit --client claude 16 | ``` 17 | 18 | ### Manual Installation 19 | ```json 20 | { 21 | "mcpServer": { 22 | "command": "uvx", 23 | "args": ["--from", "git+https://github.com/adhikasp/mcp-twikit", "mcp-twikit"], 24 | "env": { 25 | "TWITTER_USERNAME": "@example", 26 | "TWITTER_EMAIL": "[email protected]", 27 | "TWITTER_PASSWORD": "secret", 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | ## Example usage 34 | 35 | Using [mcp-client-cli](https://github.com/adhikasp/mcp-client-cli) 36 | 37 | 38 | ### Comparing sentiments across different twitter accounts 39 | 40 | ``` 41 | $ llm compare 20 latest tweet directed @IndiHomeCare, @di_cbn, @BiznetHome, @ID_MyRepublic. What are people sentiment to the product? Do 1 search for each account 42 | 43 | I'll help you analyze the latest tweets for these internet service providers in Indonesia to understand the public sentiment. I'll use the search_twitter function for each account. 44 | 45 | Tool Calls: 46 | search_twitter 47 | Args: 48 | query: to:IndiHomeCare 49 | count: 20 50 | sort_by: Latest 51 | 52 | Tool Calls: 53 | search_twitter 54 | Args: 55 | query: to:di_cbn 56 | count: 20 57 | sort_by: Latest 58 | 59 | Tool Calls: 60 | search_twitter 61 | Args: 62 | query: to:BiznetHome 63 | count: 20 64 | sort_by: Latest 65 | 66 | Tool Calls: 67 | search_twitter 68 | Args: 69 | query: to:ID_MyRepublic 70 | count: 20 71 | sort_by: Latest 72 | 73 | Sentiment Analysis Summary: 74 | 75 | 1. IndiHomeCare: 76 | - Predominantly Negative Sentiment 77 | - Key Issues: 78 | - Unstable internet connection 79 | - Slow response times 80 | - Intermittent service disruptions 81 | - Long waiting times for customer support 82 | - Specific Complaints: 83 | - Network instability 84 | - Lagging internet 85 | - Unresolved technical issues 86 | - Lack of timely communication 87 | 88 | 2. CBN (di_cbn): 89 | - Mostly Negative Sentiment 90 | - Key Issues: 91 | - Prolonged internet outages (some reporting 1-2 days of no service) 92 | - Slow customer support response 93 | - Lack of clear communication about service disruptions 94 | - Specific Complaints: 95 | - LOS (Line of Sight) issues 96 | - No timely resolution of technical problems 97 | - Delayed email and support responses 98 | 99 | 3. BiznetHome: 100 | - Strongly Negative Sentiment 101 | - Key Issues: 102 | - Frequent network interruptions 103 | - Slow internet speeds 104 | - Poor customer support responsiveness 105 | - Specific Complaints: 106 | - Packet loss 107 | - Inconsistent connection 108 | - Long download times 109 | - Unresolved technical issues 110 | 111 | 4. MyRepublic (ID_MyRepublic): 112 | - Extremely Negative Sentiment 113 | - Key Issues: 114 | - Severe and prolonged service disruptions 115 | - Complete internet outages 116 | - Inability to access online services and games 117 | - Extremely poor customer support 118 | - Specific Complaints: 119 | - Multi-day service interruptions 120 | - Inability to access websites and online games 121 | - Missed technician appointments 122 | - Lack of compensation or clear communication 123 | 124 | Overall Sentiment Ranking (from Bad to Worst): 125 | 1. IndiHomeCare (Bad) 126 | 2. CBN (Worse) 127 | 3. BiznetHome (Very Bad) 128 | 4. MyRepublic (Extremely Bad) 129 | 130 | Common Themes Across Providers: 131 | - Poor network stability 132 | - Slow customer support response 133 | - Lack of transparent communication 134 | - Prolonged service interruptions 135 | - Unresolved technical issues 136 | 137 | Recommendation: These Indonesian internet service providers are experiencing significant customer satisfaction issues. Customers are frustrated with unreliable services, poor technical support, and lack of timely problem resolution. 138 | ``` 139 | 140 | ### Getting tweets from your home timeline 141 | 142 | ``` 143 | $ llm what is happening on my twitter timeline? 144 | 145 | I'll help you check your Twitter timeline. I'll retrieve the latest tweets from your timeline: 146 | 147 | Tool Calls: 148 | get_timeline 149 | Args: 150 | 151 | I'll summarize the key highlights from your Twitter timeline: 152 | 153 | 1. Professional/Tech Highlights: 154 | - @ibamarief is preparing to onboard their first intern, noting it's a significant responsibility 155 | - @papanberjalan shared a critique about web developers not understanding computer performance 156 | - @pathofexile is running a Twitch Drops promotion for their game 157 | 158 | 2. Notable Tweets: 159 | - @elonmusk posted a cryptic "Hmm" 160 | - @realDonaldTrump discussed eliminating Daylight Saving Time 161 | - @blinkist shared an article about billionaires reading up to 50 books per year 162 | 163 | 3. Miscellaneous Interesting Tweets: 164 | - @nealtaparia shared a thread about 11 companies built around a single product 165 | - @Rixhabh__ posted about creative and iconic ads 166 | - Several tweets in Indonesian covering various topics from personal stories to social issues 167 | 168 | Would you like me to elaborate on any of these tweets or provide more context about any specific post? 169 | ``` 170 | ``` -------------------------------------------------------------------------------- /src/mcp_twikit/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "mcp-twikit" 7 | version = "0.1.0" 8 | description = "Twitter search tool for FastMCP using Twikit" 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = "MIT" 12 | authors = [ 13 | { name = "Adhika Setya Pramudita", email = "[email protected]" } 14 | ] 15 | dependencies = [ 16 | "fastmcp", 17 | "twikit", 18 | "requests", 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/adhikasp/mcp-twikit" 23 | 24 | [project.scripts] 25 | mcp-twikit = "mcp_twikit.twitter:mcp.run" ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use a Python image 3 | FROM python:3.8-slim 4 | 5 | # Set working directory 6 | WORKDIR /app 7 | 8 | # Copy the project files 9 | COPY . /app 10 | 11 | # Install the project's dependencies 12 | RUN pip install --no-cache-dir hatchling 13 | RUN pip install --no-cache-dir . 14 | 15 | # Set environment variables for Twitter authentication 16 | # These should be provided at runtime for security purposes 17 | ENV TWITTER_USERNAME "@example" 18 | ENV TWITTER_EMAIL "[email protected]" 19 | ENV TWITTER_PASSWORD "secret" 20 | 21 | # Set the entrypoint command to run the MCP server 22 | ENTRYPOINT ["mcp-twikit"] 23 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - twitterUsername 10 | - twitterEmail 11 | - twitterPassword 12 | properties: 13 | twitterUsername: 14 | type: string 15 | description: Your Twitter username. 16 | twitterEmail: 17 | type: string 18 | description: Your Twitter email. 19 | twitterPassword: 20 | type: string 21 | description: Your Twitter password. 22 | commandFunction: 23 | # A function that produces the CLI command to start the MCP on stdio. 24 | |- 25 | (config) => ({ command: 'mcp-twikit', env: { TWITTER_USERNAME: config.twitterUsername, TWITTER_EMAIL: config.twitterEmail, TWITTER_PASSWORD: config.twitterPassword } }) ``` -------------------------------------------------------------------------------- /src/mcp_twikit/twitter.py: -------------------------------------------------------------------------------- ```python 1 | from fastmcp import FastMCP, Context 2 | import twikit 3 | import os 4 | from pathlib import Path 5 | import logging 6 | from typing import Optional, List 7 | import time 8 | 9 | # Create an MCP server 10 | mcp = FastMCP("mcp-twikit") 11 | logger = logging.getLogger(__name__) 12 | httpx_logger = logging.getLogger("httpx") 13 | httpx_logger.setLevel(logging.WARNING) 14 | 15 | USERNAME = os.getenv('TWITTER_USERNAME') 16 | EMAIL = os.getenv('TWITTER_EMAIL') 17 | PASSWORD = os.getenv('TWITTER_PASSWORD') 18 | USER_AGENT = os.getenv('USER_AGENT') 19 | COOKIES_PATH = Path.home() / '.mcp-twikit' / 'cookies.json' 20 | 21 | # Rate limit tracking 22 | RATE_LIMITS = {} 23 | RATE_LIMIT_WINDOW = 15 * 60 # 15 minutes in seconds 24 | 25 | async def get_twitter_client() -> twikit.Client: 26 | """Initialize and return an authenticated Twitter client.""" 27 | client = twikit.Client('en-US', user_agent=USER_AGENT) 28 | 29 | if COOKIES_PATH.exists(): 30 | client.load_cookies(COOKIES_PATH) 31 | else: 32 | try: 33 | await client.login( 34 | auth_info_1=USERNAME, 35 | auth_info_2=EMAIL, 36 | password=PASSWORD 37 | ) 38 | except Exception as e: 39 | logger.error(f"Failed to login: {e}") 40 | raise 41 | COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True) 42 | client.save_cookies(COOKIES_PATH) 43 | 44 | return client 45 | 46 | def check_rate_limit(endpoint: str) -> bool: 47 | """Check if we're within rate limits for a given endpoint.""" 48 | now = time.time() 49 | if endpoint not in RATE_LIMITS: 50 | RATE_LIMITS[endpoint] = [] 51 | 52 | # Remove old timestamps 53 | RATE_LIMITS[endpoint] = [t for t in RATE_LIMITS[endpoint] if now - t < RATE_LIMIT_WINDOW] 54 | 55 | # Check limits based on endpoint 56 | if endpoint == 'tweet': 57 | return len(RATE_LIMITS[endpoint]) < 300 # 300 tweets per 15 minutes 58 | elif endpoint == 'dm': 59 | return len(RATE_LIMITS[endpoint]) < 1000 # 1000 DMs per 15 minutes 60 | return True 61 | 62 | # Existing search and read tools 63 | @mcp.tool() 64 | async def search_twitter(query: str, sort_by: str = 'Top', count: int = 10, ctx: Context = None) -> str: 65 | """Search twitter with a query. Sort by 'Top' or 'Latest'""" 66 | try: 67 | client = await get_twitter_client() 68 | tweets = await client.search_tweet(query, product=sort_by, count=count) 69 | return convert_tweets_to_markdown(tweets) 70 | except Exception as e: 71 | logger.error(f"Failed to search tweets: {e}") 72 | return f"Failed to search tweets: {e}" 73 | 74 | @mcp.tool() 75 | async def get_user_tweets(username: str, tweet_type: str = 'Tweets', count: int = 10, ctx: Context = None) -> str: 76 | """Get tweets from a specific user's timeline.""" 77 | try: 78 | client = await get_twitter_client() 79 | username = username.lstrip('@') 80 | user = await client.get_user_by_screen_name(username) 81 | if not user: 82 | return f"Could not find user {username}" 83 | 84 | tweets = await client.get_user_tweets( 85 | user_id=user.id, 86 | tweet_type=tweet_type, 87 | count=count 88 | ) 89 | return convert_tweets_to_markdown(tweets) 90 | except Exception as e: 91 | logger.error(f"Failed to get user tweets: {e}") 92 | return f"Failed to get user tweets: {e}" 93 | 94 | @mcp.tool() 95 | async def get_timeline(count: int = 20) -> str: 96 | """Get tweets from your home timeline (For You).""" 97 | try: 98 | client = await get_twitter_client() 99 | tweets = await client.get_timeline(count=count) 100 | return convert_tweets_to_markdown(tweets) 101 | except Exception as e: 102 | logger.error(f"Failed to get timeline: {e}") 103 | return f"Failed to get timeline: {e}" 104 | 105 | @mcp.tool() 106 | async def get_latest_timeline(count: int = 20) -> str: 107 | """Get tweets from your home timeline (Following).""" 108 | try: 109 | client = await get_twitter_client() 110 | tweets = await client.get_latest_timeline(count=count) 111 | return convert_tweets_to_markdown(tweets) 112 | except Exception as e: 113 | logger.error(f"Failed to get latest timeline: {e}") 114 | return f"Failed to get latest timeline: {e}" 115 | 116 | # New write tools 117 | @mcp.tool() 118 | async def post_tweet( 119 | text: str, 120 | media_paths: Optional[List[str]] = None, 121 | reply_to: Optional[str] = None, 122 | tags: Optional[List[str]] = None 123 | ) -> str: 124 | """Post a tweet with optional media, reply, and tags.""" 125 | try: 126 | if not check_rate_limit('tweet'): 127 | return "Rate limit exceeded for tweets. Please wait before posting again." 128 | 129 | client = await get_twitter_client() 130 | 131 | # Handle tags by converting to mentions 132 | if tags: 133 | mentions = ' '.join(f"@{tag.lstrip('@')}" for tag in tags) 134 | text = f"""{text} 135 | {mentions}""" 136 | 137 | # Upload media if provided 138 | media_ids = [] 139 | if media_paths: 140 | for path in media_paths: 141 | media_id = await client.upload_media(path, wait_for_completion=True) 142 | media_ids.append(media_id) 143 | 144 | # Create the tweet 145 | tweet = await client.create_tweet( 146 | text=text, 147 | media_ids=media_ids if media_ids else None, 148 | reply_to=reply_to 149 | ) 150 | RATE_LIMITS.setdefault('tweet', []).append(time.time()) 151 | return f"Successfully posted tweet: {tweet.id}" 152 | except Exception as e: 153 | logger.error(f"Failed to post tweet: {e}") 154 | return f"Failed to post tweet: {e}" 155 | 156 | @mcp.tool() 157 | async def delete_tweet(tweet_id: str) -> str: 158 | """Delete a tweet by its ID.""" 159 | try: 160 | client = await get_twitter_client() 161 | await client.delete_tweet(tweet_id) 162 | return f"Successfully deleted tweet {tweet_id}" 163 | except Exception as e: 164 | logger.error(f"Failed to delete tweet: {e}") 165 | return f"Failed to delete tweet: {e}" 166 | 167 | @mcp.tool() 168 | async def send_dm(user_id: str, message: str, media_path: Optional[str] = None) -> str: 169 | """Send a direct message to a user.""" 170 | try: 171 | if not check_rate_limit('dm'): 172 | return "Rate limit exceeded for DMs. Please wait before sending again." 173 | 174 | client = await get_twitter_client() 175 | 176 | media_id = None 177 | if media_path: 178 | media_id = await client.upload_media(media_path, wait_for_completion=True) 179 | 180 | await client.send_dm( 181 | user_id=user_id, 182 | text=message, 183 | media_id=media_id 184 | ) 185 | RATE_LIMITS.setdefault('dm', []).append(time.time()) 186 | return f"Successfully sent DM to user {user_id}" 187 | except Exception as e: 188 | logger.error(f"Failed to send DM: {e}") 189 | return f"Failed to send DM: {e}" 190 | 191 | @mcp.tool() 192 | async def delete_dm(message_id: str) -> str: 193 | """Delete a direct message by its ID.""" 194 | try: 195 | client = await get_twitter_client() 196 | await client.delete_dm(message_id) 197 | return f"Successfully deleted DM {message_id}" 198 | except Exception as e: 199 | logger.error(f"Failed to delete DM: {e}") 200 | return f"Failed to delete DM: {e}" 201 | 202 | def convert_tweets_to_markdown(tweets) -> str: 203 | """Convert a list of tweets to markdown format.""" 204 | result = [] 205 | for tweet in tweets: 206 | result.append(f"### @{tweet.user.screen_name}") 207 | result.append(f"**{tweet.created_at}**") 208 | result.append(tweet.text) 209 | if tweet.media: 210 | for media in tweet.media: 211 | result.append(f"") 212 | result.append("---") 213 | return "\n".join(result) 214 | ```